Public demo mode
Public demo mode
What it does
Turns one deployment of this kit into a demo anyone can click through
without signing up: two shared accounts behind one-click buttons on
/login, a banner on every page saying what this is, seeded data so no
screen is empty, and a set of guardrails so the first visitor can't break it
for the second.
It also publishes the kit's own docs/ tree at /docs, which is worth
having whether or not you run a demo.
Off unless DEMO_MODE=true. Every other deployment behaves exactly as it
did before this existed.
How to configure it
| Variable | Effect |
|---|---|
DEMO_MODE | "true" enables everything below. Anything else (including unset) disables it. |
DEMO_PASSWORD | The password the seeded accounts share, printed on the login page. Defaults to the value in scripts/seed.ts. Reseed after changing it. |
DEMO_SOURCE_URL | Where the banner's "Get the source" link points. Unset = banner with no link. |
None of them are NEXT_PUBLIC_, so none are inlined into the client
bundle. But DEMO_MODE still has to be set at build time as well as at
runtime, and that is worth being precise about:
/loginand the root layout (which carries the banner) are statically prerendered. WhateverisDemoMode()returns duringnext buildis baked into that HTML, so a build without the flag produces an image where the one-click panel and the banner can never appear — regardless of what the container's environment says.- The guardrails are different:
disabledPathsinlib/auth.tsand theblockedInDemo()checks are evaluated per process at runtime, so they follow the runtime value.
Set it in both places. On Coolify that means ticking Build Variable and
leaving it in the runtime environment; the Dockerfile has a matching
ARG DEMO_MODE. Building with it and running without gives you demo
buttons that correctly refuse to sign anyone in: safe, but confusing.
DEMO_SOURCE_URL and DEMO_PASSWORD appear in that same prerendered
markup (the banner's link, the panel's credentials hint), so they want to
be in both too.
The seeded accounts are user@example.com (a customer) and
admin@example.com (an admin), defined in DEMO_ACCOUNTS in lib/demo.ts
and created by scripts/seed.ts. @example.com is reserved by RFC 2606,
so neither can ever be a real mailbox receiving demo password-reset email.
How it works
Getting in
app/(auth)/login/page.tsx is a Server Component — that's what lets it read
DEMO_MODE per request — and passes a demo panel into
components/auth/login-form.tsx when it's on. Each button posts to
enterDemo in lib/actions/demo.ts, which calls auth.api.signInEmail
directly and then redirects to that account's landing page.
Two things follow from calling the api rather than posting to
/api/auth/sign-in/email:
- The session cookie is still set, because
nextCookies()is last in the plugin list inlib/auth.ts. That's the whole reason it's last. - better-auth's rate limiter does not run — it lives in the HTTP
router's
onRequest, which a direct call never reaches.enterDemotherefore applies its own limit vialib/rate-limit.ts, and that is the only one protecting it.
Staying usable
Two layers, because one isn't enough:
DEMO_DISABLED_PATHS (lib/demo.ts → disabledPaths in lib/auth.ts)
makes better-auth answer 404 to password change, email change, account
deletion, session revocation, admin ban/remove/set-role, and organization
deletion. This covers everything the browser's authClient calls.
blockedInDemo() / assertDemoAllowed() cover the other half. A Server
Action reaching the same capability through auth.api.* never travels over
HTTP, so disabledPaths cannot see it — banUser/unbanUser in
lib/actions/admin-users.ts check explicitly. Add the same check to any
destructive action you write.
The matching controls come off in the UI too (components/demo/demo-note.tsx
explains why in place, rather than leaving a button that 404s):
DeleteAccount, SessionsList, and UserRowActions each take a demo
prop from their Server Component parent.
Deliberately not blocked: editing your profile, creating and deleting organizations' documents, API keys, webhooks, flags, and impersonation. A demo where nothing can be changed shows nothing. That does mean a visitor can type something unpleasant into a display name and it stays there — which is what the reset below is for.
Seeding and resetting
npm run demo:seed # db:seed, then scripts/seed-demo.ts
npm run demo:reset # truncate everything, then demo:seedscripts/seed-demo.ts adds an organization with two members and a pending
invitation, notifications (two unread, so the bell has a badge), feature
flag rows, waitlist and newsletter entries, audit log entries, and a webhook
endpoint with one successful and one failed delivery. Every block checks
before inserting, so re-running it on each deploy is a no-op rather than a
duplicate.
It doesn't seed files (the rows would point at S3 objects that don't
exist, so /admin/files would list documents that 404 on click) or apikey
(the plugin hashes and formats the key itself; creating one through
/settings/api-keys demos the feature better anyway).
scripts/reset-demo.ts refuses to run unless DEMO_MODE=true, because it
truncates every table. It leaves __drizzle_migrations alone, so the schema
survives and the next deploy's migrate step stays a no-op.
The docs section
/docs renders docs/**/*.md — these files — through lib/docs.ts.
Titles and summaries are derived from the prose, so adding a file to docs/
adds a page with nothing to register. docs/README.md is skipped: the index
page replaces it.
Two details worth keeping:
format: "md".components/mdx/mdx-content.tsxtakes aformatprop, and the docs routes pass"md". These files are full of<Toaster />,{max, windowMs}andRecord<slug, Group[]>sitting in ordinary prose; MDX's default JSX parsing treats every one of those as a syntax error. In"md"they're just characters. Authored MDX undercontent/stays on the default.- Links are rewritten, not rehomed.
rewriteDocLinks()turns[auth.md](/docs/auth)into/docs/authand resolves../out ofrecipes/. Links to non-markdown paths are left alone — a reference tolib/auth.tsis a file to open in an editor, not a page on this site.
Every page is prerendered (generateStaticParams + dynamicParams = false),
so an unknown path 404s without a filesystem read. The Dockerfile copies
docs/ into the runtime image anyway — enabling analytics makes the root
layout call cookies(), which turns the tree dynamic and sends those pages
back to disk.
How to deploy it
Same image and platform as any other deployment (deployment.md) with three additions:
-
Set the build-time variables — see the note above about prerendered markup. On Coolify that is the "Build Variable" checkbox:
NEXT_PUBLIC_APP_URL=https://demo.example.com DEMO_MODE=true DEMO_SOURCE_URL=https://example.comKeep
DEMO_MODEandDEMO_SOURCE_URLin the runtime environment as well. Verify after deploying:curl https://demo.example.com/robots.txtshould end in your own domain. If it sayslocalhost:3000, the build variables didn't reach the build — and the demo panel and banner will be missing for the same reason. -
Make the post-deployment command seed as well as migrate — see deployment.md for why it must not be the pre-deployment one:
node --experimental-strip-types scripts/migrate.ts && npm run demo:seedBoth are idempotent, so this is safe on every deploy.
-
Schedule
npm run demo:reset— daily is a reasonable starting point. On Coolify that's a Scheduled Task on the application.
Give it its own database. demo:reset truncates every table, and the
DEMO_MODE interlock is the only thing standing between that command and
whatever database DATABASE_URL points at.
Set TRUSTED_PROXIES as well. Behind a proxy without it, better-auth
resolves every visitor to the same client IP and they all share one
rate-limit bucket — see security.md.
How to extend it
- A third demo account — add it to
DEMO_ACCOUNTSinlib/demo.ts(the login buttons come from that object) and create the user inscripts/seed.ts. - More seeded data — add a block to
scripts/seed-demo.ts. Check before you insert; it runs on every deploy. - A new destructive action — decide which layer it needs. Called by the
browser's
authClient? Add the path toDEMO_DISABLED_PATHS. A Server Action callingauth.api.*or the database directly? CallblockedInDemo()at the top and passdemointo its component.
How to remove it
Delete lib/demo.ts, lib/actions/demo.ts, components/demo/,
scripts/seed-demo.ts, scripts/reset-demo.ts, and this file. Drop
DEMO_MODE/DEMO_PASSWORD/DEMO_SOURCE_URL from lib/env.ts and
.env.example, the demo:seed/demo:reset scripts from package.json,
the disabledPaths line from lib/auth.ts, <DemoBanner /> from
app/layout.tsx, the blockedInDemo() guards from
lib/actions/admin-users.ts, and the demo prop from DeleteAccount,
SessionsList, and UserRowActions. Revert
app/(auth)/login/page.tsx to rendering LoginForm without a demo prop
(or fold components/auth/login-form.tsx back into it).
The docs section is independent — keep it by leaving lib/docs.ts,
lib/docs.test.ts, app/(marketing)/docs/, components/docs/, the
format prop on MdxContent, the Docs nav link, and the docs/ COPY in
the Dockerfile in place. To drop that too, delete those and remove
docs/demo.md's row from README.md.