https://github.com/vllnt/convex-slugs
Unique slug and handle registry with rename redirects, as a Convex component
https://github.com/vllnt/convex-slugs
convex convex-component slugs vllnt
Last synced: 23 days ago
JSON representation
Unique slug and handle registry with rename redirects, as a Convex component
- Host: GitHub
- URL: https://github.com/vllnt/convex-slugs
- Owner: vllnt
- License: mit
- Created: 2026-06-14T14:29:23.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-14T15:47:15.000Z (about 1 month ago)
- Last Synced: 2026-06-14T17:23:37.456Z (about 1 month ago)
- Topics: convex, convex-component, slugs, vllnt
- Language: TypeScript
- Homepage: https://www.npmjs.com/package/@vllnt/convex-slugs
- Size: 110 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
[](https://www.convex.dev/components)
[](https://www.npmjs.com/package/@vllnt/convex-slugs)
[](https://github.com/vllnt/convex-slugs/actions/workflows/ci.yml)
[](./LICENSE)
# @vllnt/convex-slugs
Unique slug and handle registry with rename redirects, as a Convex component.
```ts
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 | null
```
Reserve 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.
## Features
- **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; opaque `resourceRef`.
## Installation
```bash
pnpm add @vllnt/convex-slugs
```
Peer dependency: `convex@^1.41.0` (optionally `react@>=18` for the `./react` hooks).
## Usage
```ts
// 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;
```
```ts
// 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 = [] })`.
## API Reference
| 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](docs/API.md).
## React
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.
```tsx
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) |
## Security
- **Auth-agnostic** — the host authenticates the caller, decides who may claim a handle, and passes an opaque `resourceRef`.
- **Opaque refs only** — `resourceRef` and `scope` are 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](docs/API.md).
## Testing
```bash
pnpm test # single run
pnpm test:coverage # enforced 100% on covered files
```
Tests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Author
Built by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)
Part of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)
If this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).
## License
MIT — see [LICENSE](LICENSE).