Slug fields
A slug is the URL handle a row is addressed by — my-first-post,
kadin-giyim, acme-ltd. You declare an ordinary text column as one, and
backlex maintains it: a write that leaves it empty folds a slug out of the row’s
own title, a value that is supplied is folded to the one canonical form rather
than refused, and a collision takes the next free suffix instead of failing at
the database.
{ "name": "slug", "type": "text", "unique": true, "interface": "slug", "slug": { "from": ["title"] }}That is the whole declaration. Nothing about the storage changes — the column stays ordinary text, sortable and filterable as itself — so an existing column becomes a slug field with no migration and no DDL.
Why this is not a validation rule
Section titled “Why this is not a validation rule”Every slug column in the schema-template catalog already carried a regex. A
regex can reject My First Post!. It cannot turn it into my-first-post.
That difference is the whole feature. Before this existed, the product had seven separate slug implementations — for workspaces, organizations, SAML providers (twice), agent handles, and two in the admin item form — and they disagreed with each other on most real input:
| Input | Workspaces | Organizations | Admin auto-fill | Admin’s own box |
|---|---|---|---|---|
Ürün Kataloğu | r-n-katalo-u | urun-katalogu | r-n-katalo-u | -r-n-katalo-u |
Café Münch | caf-m-nch | cafe-munch | caf-m-nch | caf-m-nch |
Trailing space | trailing-space | trailing-space | trailing-space | -trailing-space- |
Пример | (empty) | org | (empty) | - |
The last column is the one operators actually typed into, and six of ten strings
in the corpus behind that table came out of it in a shape the column’s own regex
refuses. Typing in the box the product supplied earned a 422 naming the
field you were typing into. There is now one fold, in one place
(@backlex/db/slug), and the admin previews with the same function the server
stores with.
What gets folded, and what does not
Section titled “What gets folded, and what does not”The fold is Unicode NFKD plus a fourteen-character map, and it stops there.
NFKD splits a letter into its base plus combining marks, which are dropped:
é→e, ü→u, ğ→g, ş→s, ç→c. Fourteen Latin letters have no such
decomposition and survive it — ı đ ħ ŀ ł ʼn ø ŧ ß æ œ þ ð ŋ — so they carry an
explicit ASCII fallback (ı→i, ß→ss, æ→ae). That set is closed: it is
a finite list of letters that cannot grow.
Cyrillic, Greek, Arabic, Hebrew, Devanagari and CJK are not transliterated. Romanization is a property of the language, not of the character, and the competing standards disagree — Cyrillic alone has BGN/PCGN, ISO 9 and ALA-LC, which romanize the same name three different ways. Guessing would print a URL that looks right and is not. This is the same line phone fields draw at national number formats and geo fields draw at geocoders: bundle the dataset that is closed, refuse the one that is not.
When a title folds to nothing, the slug is left unset rather than given an
invented token like post-a3f9. That would be a working URL, but also a
permanent unreadable one nobody asked for — and the operator is right there and
can type a better one. The admin says so under the box.
The one rule for when a slug changes
Section titled “The one rule for when a slug changes”A slug is derived only when it is empty.
- A create with no slug folds one from the source column.
- An update that does not mention the slug leaves it alone — always. A
published URL must not move because somebody fixed a typo in the headline.
That breakage is exactly what the
redirectscollections in the blog and ecommerce templates exist to repair by hand. - An update that clears it re-derives from whatever the title now says. That makes “regenerate this slug” a discoverable action with no new API: empty the box and save.
Collisions
Section titled “Collisions”A slug the server derived walks summer-sale, summer-sale-2,
summer-sale-3 … and takes the first one free — one query, not one probe per
candidate.
A slug you stated is not suffixed. If it collides you get a 409 naming
your own choice, because “I want this exact URL and it is taken” deserves an
answer rather than a different URL. The server fills blanks; it does not overrule
decisions.
The check asks exactly what the column’s UNIQUE constraint asks — no tenant
clause and no soft-delete clause — because that constraint is what actually
arbitrates, and it has no predicate. A dedupe scoped more narrowly than the
constraint would confidently propose a slug the database then refuses.
Two creates racing on the same title can still both see summer-sale free; the
UNIQUE constraint is what keeps that safe, and the loser gets a 409 that a
retry resolves. The same trade sequences documents: the common
case is a person creating a second row hours later, and paying for the rare case
with a lock on every write is the wrong bargain.
Filling in the slugs a workspace never had
Section titled “Filling in the slugs a workspace never had”Every slug in the template catalog is optional and nothing ever generated one, so a workspace can hold years of rows with no handle at all.
bun backlex items backfill-slugs posts # dry run — read this firstbun backlex items backfill-slugs posts --applyawait client.from("posts").backfillSlugs({ apply: true });It is a dry run by default, bounded at 1000 rows per field per call, and
only ever fills what is empty — a row that already has a slug is never revised,
because that one may be a published URL. Text it cannot fold is reported as
unfoldable rather than given a token. Re-running is safe.
It needs update on the collection covering the slug column. A role whose
update names a field list excluding the slug is refused, the same as it is on
a direct PATCH. A role whose update carries a row condition has that
condition applied rather than being refused — unlike
rearranging a list, where renumbering a filtered subset would
collide with the rows it skipped. Each row’s slug is independent, so filling the
ones a role can see is a complete and correct operation.
Also available as POST /api/items/:slug/slugs/backfill, the backfillSlugs
GraphQL mutation, and the slug.backfill MCP tool.
Options
Section titled “Options”| Field | Meaning |
|---|---|
from | Sibling columns an empty slug is folded from, in order — the first with text wins. ["title"], ["name"]. Omit it and the field is simply a validated, folded slug the operator types: generation is opt-in, because silently deriving a public URL from a column the schema never nominated is worse than an empty box. Must name text or longtext columns. |
maxLength | Longest slug to generate. Defaults to 80, capped at 180. A collision suffix fits within it by truncating the base. |
Deliberately not supported
Section titled “Deliberately not supported”- A uniqueness
scope.uniqueis emitted as a plain column-level constraint with no predicate, so a per-parent scope in the spec would be a claim the database does not enforce — the dedupe would allow a duplicate the index then rejects. The dedupe has to ask the same question the constraint answers. - Localized slugs. A per-locale value lives in the translations sidecar
while
uniqueconstrains the base column, so per-locale slugs would be deduplicated against the wrong table and constrained by nothing. Refused at save time rather than half-supported. - Slug history and redirects. Changing a slug breaks links, and this feature
deliberately makes that hard rather than tracking it. Building redirects on top
is a content decision — the blog and ecommerce templates ship a
redirectscollection for exactly that. - Transliterating non-Latin scripts. See above.