A URL shortener, and a place to try out App Router / RSC Next.js with Kysely, Turso and Clerk.
Deployed at l.kalski.xyz.
Toolchain versions are pinned in mise.toml (mise):
mise install # Node + pnpm
cp .env.example .env.local # then fill it in, see below
pnpm install
pnpm devYou need a Clerk application for auth and a Turso database.
| Variable | Notes |
|---|---|
DATABASE_URL |
libsql://…turso.io?authToken=… |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
public; the app cannot render a page without it |
CLERK_SECRET_KEY |
can mint a session for any user — keep it out of CI |
NEXT_PUBLIC_CLERK_SIGN_{IN,UP}_URL |
/sign-in, /sign-up |
NEXT_PUBLIC_CLERK_SIGN_{IN,UP}_FALLBACK_REDIRECT_URL |
/ — note the name: AFTER_SIGN_IN_URL etc. are silently ignored |
Every route runs on the edge runtime. That is the constraint behind most of the
decisions here: on edge, @libsql/client resolves to its web build, which speaks HTTP to a
libsql server and cannot open a local SQLite file. So there is no "just use a file locally"
option — the e2e suite runs a libsql container instead.
Auth is Clerk middleware, in proxy.ts:
| Path | Behaviour |
|---|---|
/ |
protected — auth.protect() redirects to sign-in |
/api/create |
middleware runs; the handler checks auth() itself and answers 401 |
/sign-in, /sign-up |
middleware runs so Clerk's handshake can complete |
/[slug] |
public — a route handler, outside the matcher, never touches Clerk |
/api/health |
public — liveness, no Clerk |
clerkMiddleware() protects nothing on its own — the protected set above is what does it.
Removing that call makes / public with no error anywhere.
/[slug] caches lookups with unstable_cache, but never trusts a cached miss — a link
created after someone first tried the slug would otherwise keep 404ing for an hour.
Schema changes are Kysely migrations in
db_migration/migrations. They run in order and are recorded in a kysely_migration table,
so re-running applies only what is pending.
DATABASE_URL=<url> pnpm db:status # read-only: applied vs pending
DATABASE_URL=<url> pnpm db:migrate # apply everything pendingpnpm kysely-generate migrates a local local.db and regenerates types/db.d.ts from it.
Run it after adding a migration so the generated types match.
Nothing runs them for you — not the Vercel build, not CI, not the app at startup. A schema
change reaches production only when a human runs pnpm db:migrate against it.
So a deploy can ship code expecting a column that does not exist. Order matters:
| Change | Order |
|---|---|
| Additive (new table/column/index) | migrate first, then deploy |
| Destructive (drop/rename a column) | deploy code that stopped using it first, then migrate |
Run db:status before db:migrate — it connects and reports without changing anything,
which also confirms you are pointed at the database you think you are.
pnpm lint # oxlint
pnpm format # oxfmt, write
pnpm format:check # oxfmt, check only
pnpm test:unit # node --test
pnpm test:e2e # Playwright — needs Docker runningCI runs lint, format:check, test:unit, test:e2e and the build on every push and pull
request.
Vercel, on push. next build only builds — it does not migrate.
Set on Production and Preview. Do not remove it.
Vercel supports pnpm 6–10 natively and infers the
version from lockfileVersion, which pnpm 11 leaves at 9.0. Without corepack, Vercel picks
pnpm 9, which rejects a pnpm-workspace.yaml that has no packages: field — and this one
intentionally has only allowBuilds and overrides. The build fails with
ERROR packages field missing or empty. With the variable set, Vercel reads packageManager
from package.json instead.
@types/nodeis pinned to the 24.x line to match the Node version inmise.toml. Tooling keeps offering newer majors; they describe APIs that do not exist in Node 24, so code would type-check and then fail at runtime. Bump it only alongside the runtime.pnpm-workspace.yamlcarries real configuration —allowBuilds(pnpm 11 blocks dependency build scripts by default) and anoverridesentry dedupingpostcss, which Next pins to a version carrying advisories.