Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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

VariableEffect
DEMO_MODE"true" enables everything below. Anything else (including unset) disables it.
DEMO_PASSWORDThe password the seeded accounts share, printed on the login page. Defaults to the value in scripts/seed.ts. Reseed after changing it.
DEMO_SOURCE_URLWhere 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:

  • /login and the root layout (which carries the banner) are statically prerendered. Whatever isDemoMode() returns during next build is 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: disabledPaths in lib/auth.ts and the blockedInDemo() 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 in lib/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. enterDemo therefore applies its own limit via lib/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:seed

scripts/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.tsx takes a format prop, and the docs routes pass "md". These files are full of <Toaster />, {max, windowMs} and Record<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 under content/ stays on the default.
  • Links are rewritten, not rehomed. rewriteDocLinks() turns [auth.md](/docs/auth) into /docs/auth and resolves ../ out of recipes/. Links to non-markdown paths are left alone — a reference to lib/auth.ts is 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:

  1. 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.com
    

    Keep DEMO_MODE and DEMO_SOURCE_URL in the runtime environment as well. Verify after deploying: curl https://demo.example.com/robots.txt should end in your own domain. If it says localhost:3000, the build variables didn't reach the build — and the demo panel and banner will be missing for the same reason.

  2. 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:seed
    

    Both are idempotent, so this is safe on every deploy.

  3. 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_ACCOUNTS in lib/demo.ts (the login buttons come from that object) and create the user in scripts/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 to DEMO_DISABLED_PATHS. A Server Action calling auth.api.* or the database directly? Call blockedInDemo() at the top and pass demo into 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.