A small URL shortener built for Cloudflare Workers.
- Next.js 16 and React 19
- Tailwind CSS 4 and shadcn/ui (Base UI)
- OpenNext for Cloudflare Workers
- Cloudflare D1 as the source of truth
- Cloudflare KV as a disposable redirect cache
- Drizzle ORM for schema and query types
- Wrangler for local bindings and migration execution
The App Router is kept as a thin transport layer. D1 table declarations are
centralized under src/db/schema, while feature modules own domain policy,
application behavior, and feature-specific UI.
src/
├── app/ # Next.js pages and route adapters
├── components/ # shared application shell and shadcn/ui
├── db/
│ ├── client.ts # shared Drizzle D1 client
│ ├── schema-helpers.ts # shared column helpers
│ └── schema/ # users, sessions, and links tables
└── features/
├── auth/ # credential policy, D1 sessions, auth UI
└── links/
├── components/ # link-owned dashboard and form UI
├── domain/ # slug, URL, validation, and cache policy
└── server/ # D1/KV queries, mutations, and redirect behavior
All active users currently manage one global link collection. Link creator and
updater fields are audit data, not ownership. A future workspace feature can add
workspaces, workspace_members, and links.workspace_id together and backfill
the existing links into a default workspace.
The initial D1 model contains:
users: application-managed administrator identitiessessions: revocable, expiring login sessions; raw session secrets are never storedlinks: case-sensitive short-link back-halves with active, disabled, and archived states
Archived slugs remain reserved and cannot be reused. KV stores only disposable active-link cache entries; D1 remains the source of truth. The administration list uses D1-backed search and pagination instead of loading only a fixed recent subset into the browser.
See docs/data-model.md for constraints, relationships,
cache semantics, and the future workspace migration plan.
Node.js 20.9 or newer is required. Local development uses Wrangler's local D1 and KV implementations, so a Cloudflare login or provisioned remote resources are not required.
npm install
cp .dev.vars.example .dev.vars
npm run cf:typegen
npm run db:migrate:local
npm run db:seed:local
npm run devOnly copy .dev.vars.example when .dev.vars does not already exist. It contains
a local-only ADMIN_SETUP_TOKEN; replace it with a private value if the local
server is reachable by anyone else. The seed is safe to run repeatedly: it
updates the same local records instead of creating duplicates.
With the development server running, verify:
- http://localhost:3000 renders the project landing page.
- http://localhost:3000/setup creates the first active administrator. For the
unmodified example file, enter
change-link-local-setupas the setup token. - http://localhost:3000/login starts a revocable D1-backed administrator session.
- http://localhost:3000/admin supports link create, read, update, disable, archive, and restore operations after login.
- http://localhost:3000/api/health returns D1 and KV health as
ok. - http://localhost:3000/demo redirects to
https://example.com.
The seed creates a disabled local-only administrator record. It is audit data
for the demo link and cannot be used to sign in. The first administrator created
through /setup can manage that global link collection. Once one active user
exists, /setup no longer accepts another initial administrator.
Local D1 and KV data is stored in .wrangler/state and is isolated from remote
Cloudflare resources.
After the first-time setup, start the application with:
npm run devRun npm run db:migrate:local again whenever new committed migrations are
added. Stop the server with Ctrl+C.
Edit the table declaration under src/db/schema and export new tables from
src/db/schema/index.ts. Then generate and apply a reviewed SQL migration.
npm run db:generate
npm run db:migrate:localProduction migrations are applied explicitly:
npm run db:migrate:remoteDo not use drizzle-kit push against production. Migration SQL is committed to
Git and Wrangler owns migration application history.
Passwords are stored as salted PBKDF2-HMAC-SHA256 hashes. A browser receives an
HttpOnly, SameSite=Lax session cookie containing a random session ID and secret;
D1 stores only the SHA-256 digest of that secret. Disabled users, expired
sessions, and revoked sessions cannot enter /admin, and every Server Action
performs its own D1-backed authorization check. A successful login also removes
expired and revoked session rows so local authentication data does not grow
without bound.
ADMIN_SETUP_TOKEN protects the one-time /setup route from being claimed by
the first public visitor after deployment. Wrangler declares it for generated
binding types and local missing-secret warnings, while the setup action fails
closed when the binding is absent. Set the production Secret before opening the
setup route. Do not put its value in wrangler.jsonc, source code, or Git.
Before exposing /login and /setup on a public production domain, provision a
Cloudflare Turnstile widget for that hostname and connect server-side token
validation. The widget and its managed verification Worker are external
Cloudflare resources, so they are intentionally not represented by placeholder
keys in this repository. Client-side validation alone is not sufficient; follow
the Turnstile server-side validation guide.
Link mutations write to D1 and then attempt to delete link:{slug} from KV.
Cache failures are logged but do not turn a committed D1 mutation or a valid
D1-backed redirect into an application error. Cache reads use a 30-second edge
TTL, and stored values expire after five minutes to bound the stale value that a
concurrent cache miss can reintroduce. KV remains eventually consistent across
locations. Disabled and archived links return 404 after cache convergence;
restored active links redirect again. Public /{case-sensitive-slug} requests
use a temporary 302 because administrators can change their destinations.
Daily development uses the fast Next.js server. Before deployment, verify the
Worker build in the local workerd runtime:
npm run check
npm run previewOpen the local URL printed by the preview command. This builds and runs the
OpenNext Worker and is closer to the production Cloudflare runtime than
npm run dev; it still uses local bindings unless a binding is explicitly
configured as remote.
The D1 and KV bindings intentionally omit remote resource IDs for local
development. Before production automation, create the resources explicitly and
copy the returned IDs into wrangler.jsonc; this allows migrations to run before
the schema-dependent Worker is deployed.
For a first production deployment, use a high-entropy setup token and enter it interactively when prompted:
npm run test:all
npx wrangler d1 create change-link-db
npx wrangler kv namespace create LINK_CACHE
npm run db:migrate:remote
npm run deploy
npx wrangler secret put ADMIN_SETUP_TOKENCopy the D1 database_id and KV namespace id printed by the create commands
into their existing bindings in wrangler.jsonc, then regenerate bindings with
npm run cf:typegen. Do not visit /setup until the remote migrations and
Worker deployment are complete and the setup Secret has been set. For CI/CD,
pin those resource IDs and run
db:migrate:remote as an explicit step before deploying a schema-dependent
version. Use expand/migrate/contract migrations when an old and new Worker need
to overlap during a rollout.
After migration, open the deployed /setup URL once, create the administrator,
then use /login. Keeping the setup Secret configured is safe because the
application refuses initial setup whenever an active administrator exists; it
also keeps future deployments compatible with the required-secret declaration.
The test layers intentionally cover different failure modes:
npm testruns fast Vitest unit tests for auth crypto/validation and link domain policy.npm run test:integrationbuilds the OpenNext Worker, starts Cloudflare's local Test Harness, applies D1 migrations, seeds D1, and verifies D1/KV behavior.npm run test:e2ebuilds the same Worker and drives it through headless Chromium with Playwright.npm run test:allruns lint, type checking, unit, integration, and E2E tests.
Install Chromium once before the first E2E run:
npm run test:e2e:installUseful interactive commands are npm run test:watch,
npm run test:e2e:headed, and npm run test:e2e:ui.
Integration and E2E tests recreate isolated local D1 and KV storage for every
test, apply the committed migrations, and seed only the data each test needs.
They do not read or mutate the local development state under .wrangler/state,
and they never access remote Cloudflare resources. These same commands can be
placed in CI later without changing the test architecture.