opinionated slug generation for Drizzle ORM
Generate unique slugs for inserts and updates with a table-bound API, sensible defaults, and optional scoped uniqueness.
bun add @matfire/drizzle-sluggableimport { createSluggable } from "@matfire/drizzle-sluggable/pg";
import { posts } from "./schema";
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
});
const values = await postSlug.insert(db, {
title: "Hello Drizzle",
});
await db.insert(posts).values(values);
// values.slug === "hello-drizzle"If another row already uses hello-drizzle, the package generates hello-drizzle-2, hello-drizzle-3, and so on.
Use the dialect-specific entrypoint that matches your Drizzle client:
import { createSluggable } from "@matfire/drizzle-sluggable/pg";
import { createSluggable } from "@matfire/drizzle-sluggable/mysql";
import { createSluggable } from "@matfire/drizzle-sluggable/sqlite";The package also exposes named root exports if you prefer one import path for shared utilities:
import {
createPgSluggable,
createMysqlSluggable,
createSqliteSluggable,
defaultSlugify,
} from "@matfire/drizzle-sluggable";Instead of passing table, source, slugField, and update behavior on every call, you bind slug rules to a table once and reuse them:
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
});
await postSlug.insert(db, data);
await postSlug.update(db, existingPost, patch);import { integer, pgTable, serial, text, uniqueIndex } from "drizzle-orm/pg-core";
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull(),
categoryId: integer("category_id").notNull(),
},
(table) => [uniqueIndex("posts_slug_idx").on(table.slug)],
);import { createSluggable } from "@matfire/drizzle-sluggable/pg";
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
});
const values = await postSlug.insert(db, {
title: "Hello Drizzle",
categoryId: 1,
});
await db.insert(posts).values(values);import { eq } from "drizzle-orm";
import { createSluggable } from "@matfire/drizzle-sluggable/pg";
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
});
const existingPost = await db.query.posts.findFirst({
where: eq(posts.id, 1),
});
if (!existingPost) throw new Error("Post not found");
const patch = await postSlug.update(db, existingPost, {
title: "Hello Drizzle ORM",
});
await db.update(posts).set(patch).where(eq(posts.id, existingPost.id));update() needs the current row so it can compare source values and exclude that row from the uniqueness check.
If you want less fetch-then-update ceremony, you can pass a promise or loader to updateFrom():
const patch = await postSlug.updateFrom(
db,
() =>
db.query.posts.findFirst({
where: eq(posts.id, 1),
}),
{ title: "Hello Drizzle ORM" },
);createSluggable(posts) can infer a few common conventions:
to: "slug"when the table has aslugcolumnidField: "id"when the table has anidcolumnfrom: "title"orfrom: "name"when one of those columns exists- update behavior defaults to regenerating only when source fields change
When TypeScript can see those conventions, insert() and update() become available immediately. Otherwise, configure the missing pieces with .from(...), .to(...), or .usingIdField(...).
Runtime checks still exist and throw clear errors if a required field cannot be inferred.
- Add a unique index for every slug constraint you rely on.
- Use a composite unique index when slugs are scoped, for example
(category_id, slug). - Treat generated uniqueness as an application-level convenience, not a replacement for database constraints.
- Expect concurrent inserts to race in production without a DB constraint; the index is the final source of truth.
Scoped uniqueness example:
import { integer, pgTable, serial, text, uniqueIndex } from "drizzle-orm/pg-core";
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull(),
categoryId: integer("category_id").notNull(),
},
(table) => [uniqueIndex("posts_category_slug_idx").on(table.categoryId, table.slug)],
);Keep slugs unique per category, tenant, workspace, or any other subset of rows.
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
scope: ["categoryId"],
});You can also compute the scope yourself:
const postSlug = createSluggable(posts)
.from("title")
.to("slug")
.withinScope((row) => ({ categoryId: row.categoryId }));Combine several columns into one slug source:
const postSlug = createSluggable(posts, {
from: ["title", "categoryId"],
to: "slug",
});Or compute the source yourself:
const postSlug = createSluggable(posts)
.from((row) => `${row.title ?? ""} ${row.categoryId ?? ""}`)
.to("slug");const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
});
const values = await postSlug.insert(db, {
title: "Ignored for slug because one was provided",
slug: "My Custom URL",
});
// values.slug === "my-custom-url" or "my-custom-url-2"Provided slugs are still normalized and checked for uniqueness.
const postSlug = createSluggable(posts).from("title").to("slug").doNotRegenerateOnUpdate();const postSlug = createSluggable(posts).from("title").to("slug").regenerateOnEveryUpdate();const postSlug = createSluggable(posts)
.from("title")
.to("slug")
.withinScope((row) => ({ workspaceId: row.workspaceId }));const postSlug = createSluggable(posts).from("title").to("slug").preventSlugs(["admin", "new"]);If the generated slug is reserved, the package keeps adding numeric suffixes until it finds an allowed value.
const postSlug = createSluggable(posts)
.from("title")
.to("slug")
.usingSlugify((input, separator) => input.trim().toLowerCase().replace(/\s+/g, separator));Start with a config object:
const postSlug = createSluggable(posts, {
from: "title",
to: "slug",
scope: ["categoryId"],
});Or configure it fluently:
const postSlug = createSluggable(posts)
.from("title")
.to("slug")
.withinScope(["categoryId"])
.slugsShouldBeNoLongerThan(80);Available builder methods:
from(field | fields | fn)to(field)withinScope(fields | fn | object)usingSeparator(separator)slugsShouldBeNoLongerThan(length)preventSlugs(values)usingSlugify(fn)usingUniqueResolver(fn)usingIdField(field)doNotRegenerateOnUpdate()regenerateOnEveryUpdate()regenerateOnSourceChange()updateFrom(db, existing | promise | loader, patch)
This repo uses vitest for integration tests:
bun run test- PostgreSQL and MySQL tests run with
testcontainers, so Docker must be running locally. - SQLite tests use
@libsql/clientwith an in-memory database and do not require Docker. - Run the full local verification suite with
bun run check.
- The package expects a Drizzle client that can run
db.select(...).from(...).where(...). - The default slugifier lowercases, removes accents, and keeps ASCII letters and numbers.
- When source fields are empty, inserts fall back to a timestamp and updates fall back to the row id.
reservedvalues are checked against the final normalized slug value.