Unique slug and handle registry with rename redirects, as a Convex component.
const slugs = new Slugs(components.slugs);
await slugs.reserve(ctx, "ada", userId); // { ok: true } | { ok: false, reason }
const ref = await slugs.resolve(ctx, "ada"); // resourceRef | nullReserve a unique string (slug, @handle, username) against an opaque
resourceRef, enforce uniqueness inside a scope, resolve it back, release it, and
redirect on rename. Domain-neutral: article slugs, profile handles, workspace
slugs — any public-URL key.
- Atomic uniqueness per
(scope, slug)— rides the Convex mutation transaction, no double-reserve. - Scopes — global by default, or namespace per tenant / locale / type.
- Rename + redirects — renaming records an old → new redirect for link preservation.
- Chain-safe redirects — chains collapse (A→B→C ⇒
redirectFor(A) === C), one row per source. - Reverse lookup — find the slug currently held by a
resourceRef. - Case folding + NFC — case-insensitive (opt-out) and Unicode NFC-normalized to kill homoglyph collisions.
- Configurable input rules — length bounds, charset
pattern,reservedWords; typed rejection reasons. - Degrades, never throws — reads use
.first(); release cleans up inbound redirects; opaqueresourceRef.
pnpm add @vllnt/convex-slugsPeer dependency: convex@^1.41.0 (optionally react@>=18 for the ./react hooks).
// convex/convex.config.ts
import { defineApp } from "convex/server";
import slugs from "@vllnt/convex-slugs/convex.config";
const app = defineApp();
app.use(slugs);
export default app;// convex/handles.ts — host owns auth; pass an opaque resourceRef in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Slugs } from "@vllnt/convex-slugs";
const slugs = new Slugs(components.slugs);
export const claim = mutation({
args: { handle: v.string(), userId: v.string() },
handler: (ctx, { handle, userId }) => slugs.reserve(ctx, handle, userId),
});
export const lookup = query({
args: { handle: v.string() },
handler: (ctx, { handle }) => slugs.resolve(ctx, handle),
});Client options: new Slugs(component, { defaultScope = "global", foldCase = true, minLength = 1, maxLength = 256, pattern?, reservedWords = [] }).
| Method | Kind | Result |
|---|---|---|
reserve(ctx, slug, resourceRef, scope?) |
mutation | { ok: true } or { ok: false, reason } (SLUG_INVALID | SLUG_RESERVED | SLUG_TAKEN) |
release(ctx, slug, scope?) |
mutation | null (idempotent) |
rename(ctx, fromSlug, toSlug, scope?) |
mutation | { ok } (SLUG_INVALID | SLUG_NOT_FOUND | SLUG_TAKEN) |
resolve(ctx, slug, scope?) |
query | resourceRef | null |
redirectFor(ctx, slug, scope?) |
query | toSlug | null |
slugForResource(ctx, resourceRef, scope?) |
query | slug | null |
Full reference: docs/API.md.
Optional, tree-shakeable hooks from @vllnt/convex-slugs/react — thin wrappers over
useQuery; react is an optional peer dep. Each takes the host's re-exported
resolve query reference.
import { useSlugAvailable } from "@vllnt/convex-slugs/react";
import { api } from "../convex/_generated/api";
// `available` is undefined while loading, true when free, false when held.
const { available, resourceRef } = useSlugAvailable(api.handles.resolveHandle, { slug });| Hook | Returns |
|---|---|
useSlugAvailable(resolveRef, { slug, scope? }) |
{ available, resourceRef } — available undefined while loading, true free, false held |
useResolve(resolveRef, { slug, scope? }) |
resourceRef | null | undefined (undefined while loading) |
- Auth-agnostic — the host authenticates the caller, decides who may claim a handle, and passes an opaque
resourceRef. - Opaque refs only —
resourceRefandscopeare arbitrary strings; tables are sandboxed (reached only via the client). - Boundary re-guard — the component re-guards length at the trust boundary even if a caller bypasses the client.
See docs/API.md.
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
See CONTRIBUTING.md.
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
MIT — see LICENSE.