Testing
Run end-to-end tests with Playwright in Next Starter: a setup project signs in once so tests start already logged in, plus how to add and run new specs.
Next Starter ships a Playwright end-to-end (E2E) suite. It runs against a production build on localhost:3000. Tests live in e2e/, configured by playwright.config.ts.
There is no separate test database. playwright.config.ts overrides exactly one environment variable, a Turnstile test secret, so DATABASE_URL comes from your shell or your .env. And pnpm test:e2e starts with pnpm build, which ends in prisma migrate deploy, so migrations land before the first test runs. The tests then write: they sign in, sign out, and save settings changes as e2e@test.com. Aim that at a production database and it will migrate and mutate production. Check DATABASE_URL before every run.
There's no unit test framework, by design. Zod validates data at runtime, TypeScript strict mode catches type errors, pnpm build proves the app compiles, and the E2E suite covers the user flows on top of that.
How it works
Playwright runs three projects from playwright.config.ts. A project is a named group of tests with its own browser, device and file filter.
| Project | What it does |
|---|---|
setup | Signs in once. Both other projects declare dependencies: ["setup"], so it always runs first |
chromium | Runs every *.spec.ts on the Desktop Chrome profile (Chromium), already logged in |
iphone | Runs only smoke.spec.ts on the iPhone 14 Pro profile (WebKit), already logged in |
Locally everything runs in parallel with retries off. In CI (anything that sets CI) it drops to a single worker and retries twice. trace: "on-first-retry" applies to every run, but since local runs never retry, in practice traces come from CI, where you can replay one to debug the failure. The webServer block starts the app with pnpm start. Locally it reuses whatever already answers on port 3000, so a pnpm dev you forgot to stop is what gets tested. In CI it starts its own server and fails outright if something else is holding the port.
Sign in once, reuse the session
Signing in before every test is slow, so the suite logs in one time and the tests reuse it. auth.spec.ts is the exception: both of its blocks clear that state, and the sign-out test signs in on its own, because signing out revokes the shared session for every spec that runs after it. e2e/auth.setup.ts POSTs to Better Auth's /api/auth/sign-in/email endpoint, no browser and no OAuth, then writes the session cookies to playwright/.auth/user.json. The chromium and iphone projects load that file through storageState, so they open already logged in.
There's no bot check to clear on that request. Turnstile guards the contact form only, not the auth routes, so the sign-in call goes straight through. See Turnstile. The contact form still needs TURNSTILE_SECRET_KEY, which the app validates at boot, so playwright.config.ts starts the server with Cloudflare's always-pass test secret. It's test-only and never used in production.
The email and password are hardcoded as TEST_USER in e2e/credentials.ts (the user is e2e@test.com), and there are no E2E_* environment variables. Create that user in your database before the first run. Nothing in the repo does it, and the app rejects e2e@test.com at registration because test.com is in lib/email/blocked-domains.json, so register any non-disposable address with that password and re-point the row in SQL. It needs a verified email, or sign-in fails on the requireEmailVerification: true in lib/auth.ts. onboardingComplete has to be true too, or the dashboard layout sends every dashboard test to /onboarding. And it needs the admin role, or /dashboard/users bounces straight back to /dashboard. The comment at the top of e2e/auth.setup.ts has the UPDATE statement. Prefer your own values? Edit e2e/credentials.ts.
Running tests
| Command | What it does |
|---|---|
pnpm test:e2e | Runs pnpm build first, then the full Playwright suite |
pnpm test:e2e:ui | Opens Playwright's interactive UI runner |
pnpm test:e2e:report | Serves the HTML report from the last run on port 9323 |
The build script is prisma generate && next build && prisma migrate deploy, so pnpm test:e2e always produces a fresh production build. Locally, though, an app already on port 3000 is what gets tested, as noted above. :ui skips that step, so build yourself first or pnpm start will have nothing to serve. :report needs neither a build nor a running app.
What's covered
| Test file | What it checks |
|---|---|
smoke.spec.ts | Homepage loads, /api/health returns 200, 404 page renders |
auth.spec.ts | Sign out works; a signed-out user is redirected to /auth/sign-in |
dashboard.spec.ts | Billing, admin users, and profile pages load for the test user |
settings.spec.ts | Notification preferences show, and saving them works |
contact.spec.ts | Contact page loads, form validation fires in the browser |
seo.spec.ts | Meta title, description, and Open Graph tags are present |
The suite does not cover Stripe webhooks, email sending, Google OAuth, or file uploads, which need R2/S3 connectivity. Test those by hand or in staging, and use the Stripe CLI for the webhooks.
Adding a test
Drop a new *.spec.ts file into e2e/. Playwright picks it up, and chromium runs it with the saved admin session already loaded, so you never sign in by hand. To test a signed-out flow, clear that session for the block, the way auth.spec.ts does:
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
// ...goes to /dashboard, expects redirect to /auth/sign-in
});Which layer does the redirecting
proxy.ts at the repo root is the Next.js proxy file, which runs before a matched request is completed. It matches /dashboard/:path* and redirects to /auth/sign-in when the session cookie is missing, but it only checks that the cookie exists, never that it's valid. Its own comment says as much. That's the layer catching the signed-out case in auth.spec.ts.
The checks that enforce anything run in Server Components. app/dashboard/layout.tsx calls getSession() and redirects when there is no session, or to /onboarding when onboardingComplete is false. app/dashboard/(admin)/layout.tsx sends anyone whose role is not "admin" back to /dashboard. Individual pages call getSession() again as a second layer. See Authentication for the full picture.
Docker Deployment
Self-host Next Starter with Docker: a multi-stage Dockerfile, Next.js standalone output, and the build args versus runtime env split explained.
SEO
How Next Starter handles metadata, Open Graph, Twitter Cards, sitemaps, robots.txt, and JSON-LD structured data from a single config layer in lib/config.ts.