Testing
How Next Starter uses Playwright for end-to-end testing across authentication flows, protected routes, forms, SEO metadata, and smoke tests.
Next Starter includes a full E2E testing setup using Playwright. Tests run against a production build on localhost:3000 with a local Docker Postgres database — never production.
No unit test framework is included. Zod schemas validate data at runtime, TypeScript strict mode catches type errors at compile time, and pnpm build verifies the entire app compiles correctly. E2E tests verify that critical flows work end-to-end. For a project this size, this combination is sufficient.
Running Tests
# Run full E2E suite (builds first, then runs Playwright)
pnpm test:e2e
# Open Playwright's interactive UI (run pnpm build first)
pnpm test:e2e:ui
# View the HTML report from the last run
pnpm test:e2e:reportConfiguration
The Playwright config lives at playwright.config.ts in the project root.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
},
{
name: "iphone",
use: {
...devices["iPhone 14 Pro"],
storageState: "playwright/.auth/user.json",
},
dependencies: ["setup"],
testMatch: /smoke\.spec\.ts/,
},
],
webServer: {
command: "pnpm start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
},
});Key details:
- Test directory:
./e2e - Parallel execution: Full parallelism locally, single worker in CI
- Retries: 2 retries in CI, none locally
- Traces: Captured on first retry for debugging failures
- Projects: A setup project handles authentication, then Chromium runs all tests and iPhone runs smoke tests
- Web server: Starts the production server automatically via
pnpm start
Authentication Setup
Tests that need an authenticated session share a single auth setup. The setup project runs before all other test projects and saves the session state for reuse.
// e2e/auth.setup.ts
import { test as setup } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
setup("authenticate", async ({ request }) => {
const response = await request.post("/api/auth/sign-in/email", {
data: {
email: "contact+e2e@fuzesoft.com",
password: "fADGD7UzY9MI",
},
});
if (!response.ok()) {
throw new Error(
`Auth setup failed: ${response.status()} ${await response.text()}`,
);
}
await request.storageState({ path: authFile });
});This calls Better Auth's sign-in API directly — no browser, no reCAPTCHA, no OAuth flow. The session cookies are saved to playwright/.auth/user.json and loaded automatically by test projects that declare storageState.
The test user must exist in your local database before tests run. Create one with a verified email address and admin role.
Test Suites
All tests live in the e2e/ directory.
Smoke Tests
Basic checks that the app boots and responds correctly.
// e2e/smoke.spec.ts
test("homepage loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Next Starter/);
});
test("health check returns 200", async ({ request }) => {
const response = await request.get("/api/health");
expect(response.ok()).toBeTruthy();
expect(await response.text()).toBe("OK");
});
test("404 page renders for unknown routes", async ({ page }) => {
await page.goto("/nonexistent-page");
await expect(
page.getByRole("heading", { name: /page not found/i }),
).toBeVisible();
});Authentication
Verifies that auth flows and route protection work correctly.
// e2e/auth.spec.ts
test("user can sign out", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "User menu" }).click();
await page.getByRole("menuitem", { name: /sign out/i }).click();
await expect(page).toHaveURL(/\/auth\/sign-in/);
});
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("unauthenticated user is redirected to sign-in", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/auth\/sign-in/);
});
});The unauthenticated test clears the storage state so it runs without session cookies, verifying that the dashboard layout redirects to /auth/sign-in.
Dashboard Routes
Confirms that all protected dashboard pages load for an authenticated user.
// e2e/dashboard.spec.ts
test("billing page loads", async ({ page }) => {
await page.goto("/dashboard/billing");
await expect(page).toHaveURL(/billing/);
});
test("admin users page loads", async ({ page }) => {
await page.goto("/dashboard/users");
await expect(page).toHaveURL(/users/);
});
test("profile page loads", async ({ page }) => {
await page.goto("/dashboard/profile");
await expect(page).toHaveURL(/profile/);
});Settings
Tests the notification preferences UI and save functionality.
// e2e/settings.spec.ts
test("settings page shows notification preferences", async ({ page }) => {
await page.goto("/dashboard/settings");
await expect(page.getByText("Product updates")).toBeVisible();
await expect(page.getByText("Marketing emails")).toBeVisible();
});
test("can toggle and save notification settings", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.getByLabel("Product updates").click();
await page.getByRole("button", { name: /save/i }).click();
await expect(page.getByText(/settings have been updated/i)).toBeVisible();
});Contact Form
Verifies that the contact page renders and form validation works.
// e2e/contact.spec.ts
test("contact page loads", async ({ page }) => {
await page.goto("/contact");
await expect(
page.getByRole("heading", { name: /start a conversation/i }),
).toBeVisible();
});
test("contact form shows validation errors on empty submit", async ({
page,
}) => {
await page.goto("/contact", { waitUntil: "networkidle" });
await page.getByRole("button", { name: /send message/i }).click();
await expect(page.getByText(/name is required/i).first()).toBeVisible();
});SEO Metadata
Checks that critical meta tags are present on the homepage.
// e2e/seo.spec.ts
test("homepage has correct meta title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(
"Next Starter - The Trusted Next.js Starter Template",
);
});
test("homepage has meta description", async ({ page }) => {
await page.goto("/");
const description = page.locator('meta[name="description"]');
await expect(description).toHaveAttribute("content", /production-ready/i);
});
test("homepage has open graph tags", async ({ page }) => {
await page.goto("/");
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
"content",
/.+/,
);
await expect(
page.locator('meta[property="og:description"]'),
).toHaveAttribute("content", /.+/);
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
/.+/,
);
});Test Coverage Summary
| Test file | What it catches |
|---|---|
smoke.spec.ts | App boots, server responds, 404 page renders |
auth.spec.ts | Auth sessions work, sign out works, route protection redirects |
dashboard.spec.ts | Protected pages render for authenticated users |
settings.spec.ts | Settings UI renders and saves correctly |
contact.spec.ts | Contact page renders, form validation works in browser |
seo.spec.ts | Meta title, description, and Open Graph tags are present |
What Is Not Tested
These areas are intentionally excluded from E2E tests:
- Stripe webhook processing — test via the Stripe CLI separately (
stripe listen --forward-to) - Email sending — test via staging environment or manually
- Google OAuth flow — external service, not under your control
- File uploads — requires R2/S3 connectivity, test manually or add later
Adding New Tests
Create a new .spec.ts file in the e2e/ directory. Playwright picks it up automatically.
// e2e/my-feature.spec.ts
import { test, expect } from "@playwright/test";
test("my new page loads", async ({ page }) => {
await page.goto("/my-page");
await expect(page.getByRole("heading", { name: /my page/i })).toBeVisible();
});Tests that need authentication inherit the stored session from the Chromium project automatically. To test an unauthenticated flow within an authenticated project, clear the storage state:
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("redirects without session", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/auth\/sign-in/);
});
});How Route Protection Works
This project does not use Next.js middleware for auth. Route protection is handled at the Server Component level:
- Dashboard layout (
app/dashboard/layout.tsx): CallsgetSession()and redirects to/auth/sign-inif no session, then redirects to/onboardingifsession.user.onboardingCompleteis false - Admin layout (
app/dashboard/(admin)/layout.tsx): Checkssession.user.role !== "admin"and redirects to/dashboard - Individual pages: Also call
getSession()as defense-in-depth
Unauthenticated users receive a server-side redirect (HTTP 307) to /auth/sign-in.
Project Structure
playwright.config.ts # Playwright configuration
e2e/
├── auth.setup.ts # Global auth setup (runs first)
├── auth.spec.ts # Auth flow tests
├── contact.spec.ts # Contact page tests
├── dashboard.spec.ts # Dashboard route tests
├── seo.spec.ts # SEO metadata tests
├── settings.spec.ts # User settings tests
└── smoke.spec.ts # Basic smoke tests
playwright/
└── .auth/ # (gitignored) Stored auth state
└── user.jsonThe following directories are gitignored:
playwright/.auth/— stored session statetest-results/— test execution outputplaywright-report/— HTML reportsblob-report/— CI blob reports
Docker Deployment
Self-host Next Starter with Docker using the included Dockerfile, docker-compose, and Next.js standalone output mode. Covers building, environment variables, and running database migrations.
SEO
How Next Starter handles metadata, Open Graph tags, Twitter Cards, sitemaps, robots.txt, and JSON-LD structured data through a single configuration layer in lib/config.ts.