diff --git a/.env.example b/.env.example index ea59663..89289c7 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ BETTER_AUTH_SECRET= # Full absolute URL of the deployed app, e.g. https://example.com BETTER_AUTH_URL= +# Public auth URL for the browser client (must match BETTER_AUTH_URL in prod) +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 + # Optional: database URL for better-auth (Kysely/Drizzle adapter) # DATABASE_URL=postgres://user:pass@localhost:5432/db diff --git a/README.md b/README.md index e215bc4..f286ebb 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,117 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Next.js Template -## Getting Started +Production-ready Next.js 16 (App Router, Turbopack) starter on Bun, with +shadcn/ui Base UI components, TypeScript strict, and a zero-warning +lint/typecheck/test/build pipeline. -First, run the development server: +## Stack + +- **Framework:** Next.js 16.3 (App Router, Turbopack, standalone output) +- **Runtime:** Bun 1.3.14 +- **UI:** shadcn/ui Base-Nova (Base UI) + Tailwind CSS v4, lucide-react, motion +- **Data:** @tanstack/react-query, @tanstack/react-table +- **Forms:** react-hook-form + zod (v4) + @hookform/resolvers +- **Auth:** better-auth (memory adapter, swap to Drizzle/Postgres when ready) +- **API client:** openapi-fetch + openapi-typescript, ky +- **Markdown:** react-markdown + remark-gfm + shiki (server-side highlighting) +- **Misc:** zustand, nuqs, sonner, cmdk, vaul, embla-carousel-react, date-fns, + next-themes, recharts + +## Getting started ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +cp .env.example .env # optional: set BETTER_AUTH_SECRET / BETTER_AUTH_URL +bun run dev # http://localhost:3000 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Requires Node ≥ 18 (Bun runtime) — see `packageManager` in package.json. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Scripts -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +| Script | Description | +| --------------------------------- | ------------------------------------------------------------------- | +| `bun run dev` | Start dev server (Turbopack, NODE_ENV pinned to development) | +| `bun run build` | Production build (standalone output for Docker) | +| `bun run start` | Serve the production build | +| `bun run lint` | ESLint (next core-web-vitals + typescript) | +| `bun run lint:fix` | ESLint with autofix | +| `bun run typecheck` | `tsc --noEmit` (strict + noUnusedLocals/Parameters) | +| `bun run test` | Vitest unit tests (jsdom + jest-dom) | +| `bun run test:watch` | Vitest watch mode | +| `bun run test:coverage` | Vitest with v8 coverage report | +| `bun run test:e2e` | Playwright E2E (Chromium, dedicated :3100) | +| `bun run format` / `format:check` | Prettier write / check (Tailwind v4 plugin) | +| `bun run api:generate` | Regenerate `types/api.ts` from `/api/openapi` (dev server must run) | +| `bun run prepare` | Install husky hooks | -## Learn More +Git hooks (husky + lint-staged) run ESLint + Prettier on staged files. -To learn more about Next.js, take a look at the following resources: +## Project structure -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +``` +app/ + page.tsx # landing page + demo/ # wiring example: markdown+shiki, form, table, session + protected/ # route guarded by middleware (better-auth session) + error.tsx / loading.tsx / not-found.tsx + api/ + health/ # GET health JSON + openapi/ # OpenAPI 3.1 spec for codegen + auth/[...all]/ # better-auth handler + items/ # GET list / POST create (zod-validated) +components/ + providers.tsx # QueryClient + Theme + Nuqs + Tooltip + Sonner + ui/ # shadcn Base-Nova components (CLI-managed) + demo/ # form + table wiring examples +hooks/ # use-mobile (useSyncExternalStore) +lib/ + auth.ts # better-auth server (memory adapter) + auth-client.ts # better-auth react client + api.ts # typed openapi-fetch client + http.ts # ky wrapper + query-client.ts # react-query client (per-request on server) + env.ts # zod-validated env (no import-time throw) +types/api.ts # generated openapi-typescript types +middleware.ts # auth route guard +e2e/ # Playwright specs +.github/workflows/ci.yml +Dockerfile # multi-stage standalone build +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Auth -## Deploy on Vercel +better-auth is wired but intentionally runs on the in-memory adapter so the +template builds without a database. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +1. `bunx @better-auth/cli secret` → put the result in `BETTER_AUTH_SECRET`. +2. Set `BETTER_AUTH_URL` (+ `NEXT_PUBLIC_BETTER_AUTH_URL` for the client). +3. To persist users, swap `memoryAdapter({})` in `lib/auth.ts` for a real + adapter (e.g. `drizzleAdapter(db, { provider: "pg" })`) and add the DB URL. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Routes under `/protected` require a session (see `middleware.ts`). + +## OpenAPI + typed client + +`app/api/openapi` serves an OpenAPI 3.1 document. Regenerate the typed client: + +```bash +bun run dev & # server must be up +bun run api:generate # writes types/api.ts +``` + +Then use `lib/api.ts` (`createClient` from openapi-fetch). + +## Docker + +```bash +docker build -t nextjs-template . +docker run -p 3000:3000 nextjs-template +``` + +Multi-stage standalone image (~50MB runtime); runs as non-root user; requires +NODE_ENV=production and the env vars above at runtime. + +## License + +Private template. diff --git a/app/api/items/route.ts b/app/api/items/route.ts new file mode 100644 index 0000000..b529914 --- /dev/null +++ b/app/api/items/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { createItemSchema, MOCK_ITEMS, type Item, type CreateItemOutput } from "@/lib/items"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ items: MOCK_ITEMS }); +} + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const parsed = createItemSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const input: CreateItemOutput = parsed.data; + const item: Item = { + id: crypto.randomUUID(), + ...input, + createdAt: new Date().toISOString(), + }; + + // In a real app: persist to DB here. MOCK_ITEMS is module-scoped, so the + // created item lives only for the lifetime of the server process. + MOCK_ITEMS.unshift(item); + + return NextResponse.json({ item }, { status: 201 }); +} diff --git a/app/demo/page.tsx b/app/demo/page.tsx new file mode 100644 index 0000000..09d8ed3 --- /dev/null +++ b/app/demo/page.tsx @@ -0,0 +1,61 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { CodeBlock } from "@/components/ui/code-block"; +import { DemoClient } from "@/components/demo/demo-client"; + +const SAMPLE_MD = `# Demo Page + +This page shows the core wiring of the template: + +- **Server component** renders this markdown via \`react-markdown\` + \`remark-gfm\` +- **Shiki** highlights code blocks server-side +- **React Query** fetches \`/api/items\` +- **React Hook Form + Zod** validates the "Add item" form +- **better-auth** session is read on the server below + +## Code sample + +\`\`\`ts +export function getGreeting(name: string): string { + return \`Hello, \${name}!\`; +} +\`\`\` + +> Tip: copy this page as a starting point for real features. +`; + +export default function DemoPage() { + return ( +
+
+ + ); + } + return ( + + {children} + + ); + }, + }} + > + {SAMPLE_MD} + +
+ + +
+ ); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..bb49632 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

Something went wrong

+

+ {error.message || "An unexpected error occurred."} +

+ {error.digest ? ( +

Error ID: {error.digest}

+ ) : null} + +
+ ); +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..0bc2320 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+ + Loading… +
+
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9d0c07a --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+

Page not found

+

+ The page you are looking for does not exist or has been moved. +

+ + Back to home + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index d40116c..704ce16 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import Link from "next/link"; export default function Home() { return ( @@ -37,6 +38,12 @@ export default function Home() { {" "} center.

+ + View the demo wiring → +
+

Protected Page

+ {session ? ( +
+

+ Welcome, {session.user.name}!{" "} + ({session.user.email}) +

+

+ Session expires: {new Date(session.session.expiresAt).toLocaleString()} +

+
+ ) : ( +

No session found.

+ )} + + ← Back home + + + ); +} diff --git a/bun.lock b/bun.lock index 3d6eceb..3a93df8 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^5.0.1", "eslint": "^9", "eslint-config-next": "16.3.5", "husky": "^9.1.7", @@ -140,6 +141,8 @@ "@base-ui/utils": ["@base-ui/utils@0.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@better-auth/core": ["@better-auth/core@1.7.5", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q=="], @@ -604,6 +607,12 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@5.0.1", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", "@vitest/istanbul-lib-report": "^1.0.0", "ast-v8-to-istanbul": "^1.0.5", "magicast": "^0.5.4", "obug": "^2.1.4", "std-env": "^4.2.0", "tinyrainbow": "^3.1.1" }, "peerDependencies": { "@vitest/browser": "5.0.1", "vitest": "5.0.1" }, "optionalPeers": ["@vitest/browser"] }, "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w=="], + + "@vitest/istanbul-lib-coverage": ["@vitest/istanbul-lib-coverage@1.0.1", "", {}, "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA=="], + + "@vitest/istanbul-lib-report": ["@vitest/istanbul-lib-report@1.0.1", "", { "dependencies": { "@vitest/istanbul-lib-coverage": "1.0.1" } }, "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ=="], + "@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="], "@vitest/spy": ["@vitest/spy@5.0.1", "", {}, "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q=="], @@ -654,6 +663,8 @@ "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], @@ -1290,6 +1301,8 @@ "magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="], + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1764,6 +1777,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@7.4.13", "", { "dependencies": { "tldts-core": "^7.4.13" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA=="], "tldts-core": ["tldts-core@7.4.13", "", {}, "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg=="], @@ -1980,6 +1995,8 @@ "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/components/demo/demo-client.tsx b/components/demo/demo-client.tsx new file mode 100644 index 0000000..95b2bf5 --- /dev/null +++ b/components/demo/demo-client.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { ItemForm } from "@/components/demo/item-form"; +import { ItemList } from "@/components/demo/item-list"; + +export function DemoClient() { + return ( +
+
+ +
+

Items (react-query + react-table)

+ +
+
+
+ ); +} diff --git a/components/demo/item-form.tsx b/components/demo/item-form.tsx new file mode 100644 index 0000000..24cb530 --- /dev/null +++ b/components/demo/item-form.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +const itemSchema = z.object({ + name: z.string().min(1, "Name is required").max(100), + quantity: z.coerce + .number({ error: "Quantity must be a number" }) + .int("Must be a whole number") + .min(0, "Minimum 0") + .max(10000, "Maximum 10000"), +}); + +type ItemFormValues = z.input; +type ItemSubmitValues = z.output; + +export function ItemForm() { + const queryClient = useQueryClient(); + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(itemSchema), + defaultValues: { name: "", quantity: 1 }, + }); + + const mutation = useMutation({ + mutationFn: async (values: ItemSubmitValues) => { + const res = await fetch("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error ?? "Failed to create item"); + } + return res.json(); + }, + onSuccess: () => { + toast.success("Item created"); + reset(); + queryClient.invalidateQueries({ queryKey: ["items"] }); + }, + onError: (err: Error) => { + toast.error(err.message); + }, + }); + + return ( +
mutation.mutate(values))} + className="flex flex-col gap-3 rounded-lg border p-4" + noValidate + > +

Add item (RHF + zod + react-query)

+ +
+ + + {errors.name && {errors.name.message}} +
+ +
+ + + {errors.quantity && ( + {errors.quantity.message} + )} +
+ + +
+ ); +} diff --git a/components/demo/item-list.tsx b/components/demo/item-list.tsx new file mode 100644 index 0000000..5609910 --- /dev/null +++ b/components/demo/item-list.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { flexRender } from "@tanstack/react-table"; +import { + legacyCreateColumnHelper as createColumnHelper, + getCoreRowModel, + useLegacyTable, + type LegacyColumnDef, +} from "@tanstack/react-table/legacy"; +import { format } from "date-fns"; +import type { Item } from "@/lib/items"; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor("name", { + header: "Name", + }), + columnHelper.accessor("quantity", { + header: "Qty", + }), + columnHelper.accessor("createdAt", { + header: "Created", + cell: (info) => format(new Date(info.getValue()), "MMM d, HH:mm"), + }), +] as LegacyColumnDef[]; + +export function ItemList() { + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["items"], + queryFn: async () => { + const res = await fetch("/api/items"); + if (!res.ok) throw new Error("Failed to load items"); + const body = await res.json(); + return body.items as Item[]; + }, + }); + + const table = useLegacyTable({ + data: data ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (isLoading) { + return

Loading items…

; + } + + if (isError) { + return ( +
+ {error.message} + +
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {data?.length === 0 ? ( +

No items yet.

+ ) : null} +
+ ); +} diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts new file mode 100644 index 0000000..89a23e3 --- /dev/null +++ b/e2e/demo.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "@playwright/test"; + +test("demo page shows markdown, code block, and items table", async ({ page }) => { + await page.goto("/demo"); + + // Markdown rendered + await expect(page.getByRole("heading", { name: "Demo Page" })).toBeVisible(); + await expect(page.getByText("Server component renders this markdown")).toBeVisible(); + + // Shiki code block (highlighted
 wrapper)
+  const codeBlock = page.locator("pre");
+  await expect(codeBlock.first()).toBeVisible();
+
+  // Items table loaded via react-query
+  await expect(page.getByText("Ergonomic chair")).toBeVisible();
+  await expect(page.getByText("Mechanical keyboard")).toBeVisible();
+});
+
+test("demo form creates an item (RHF + zod + mutation)", async ({ page }) => {
+  await page.goto("/demo");
+
+  const name = "Playwright gadget";
+  await page.getByLabel("Name").fill(name);
+  await page.getByLabel("Quantity").fill("5");
+  await page.getByRole("button", { name: "Create item" }).click();
+
+  await expect(page.getByText("Item created")).toBeVisible(); // sonner toast
+  await expect(page.getByText(name)).toBeVisible(); // appears in table
+
+  // Zod validation: empty name shows error
+  await page.getByLabel("Name").fill("");
+  await page.getByRole("button", { name: "Create item" }).click();
+  await expect(page.getByText("Name is required")).toBeVisible();
+});
+
+test("protected page redirects to home when unauthenticated", async ({ page }) => {
+  await page.goto("/protected");
+  await expect(page).toHaveURL(/\/\?next=%2Fprotected/);
+});
diff --git a/lib/__tests__/items-schema.test.ts b/lib/__tests__/items-schema.test.ts
new file mode 100644
index 0000000..71ca84c
--- /dev/null
+++ b/lib/__tests__/items-schema.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { createItemSchema } from "@/lib/items";
+
+describe("items API schema", () => {
+  it("accepts valid input (coerces numeric strings)", () => {
+    const result = createItemSchema.safeParse({ name: "Chair", quantity: "4" });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data).toEqual({ name: "Chair", quantity: 4 });
+    }
+  });
+
+  it("rejects empty name", () => {
+    const result = createItemSchema.safeParse({ name: "", quantity: 1 });
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.flatten().fieldErrors.name).toBeTruthy();
+    }
+  });
+
+  it("rejects non-numeric quantity", () => {
+    const result = createItemSchema.safeParse({ name: "Chair", quantity: "abc" });
+    expect(result.success).toBe(false);
+  });
+
+  it("rejects negative quantity", () => {
+    const result = createItemSchema.safeParse({ name: "Chair", quantity: -5 });
+    expect(result.success).toBe(false);
+  });
+});
diff --git a/lib/auth-client.ts b/lib/auth-client.ts
index 25591c4..f6f2b99 100644
--- a/lib/auth-client.ts
+++ b/lib/auth-client.ts
@@ -1,7 +1,8 @@
 import { createAuthClient } from "better-auth/react";
+import { env } from "@/lib/env";
 
 export const authClient = createAuthClient({
-  baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "http://localhost:3000",
+  baseURL: env.NEXT_PUBLIC_BETTER_AUTH_URL,
 });
 
 export const { useSession, signIn, signUp, signOut } = authClient;
diff --git a/lib/env.ts b/lib/env.ts
new file mode 100644
index 0000000..7f90a91
--- /dev/null
+++ b/lib/env.ts
@@ -0,0 +1,21 @@
+import { z } from "zod";
+
+const envSchema = z.object({
+  NEXT_PUBLIC_BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
+});
+
+/**
+ * Validated public env vars. Uses .default() so missing vars don't throw at
+ * import time (keeps next build + CI safe); values fall back to dev defaults.
+ */
+const parsed = envSchema.safeParse({
+  NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
+});
+
+export const env = parsed.success
+  ? parsed.data
+  : {
+      NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000",
+    };
+
+export const isEnvValid = () => parsed.success;
diff --git a/lib/items.ts b/lib/items.ts
new file mode 100644
index 0000000..2120232
--- /dev/null
+++ b/lib/items.ts
@@ -0,0 +1,23 @@
+import { z } from "zod";
+
+export type Item = {
+  id: string;
+  name: string;
+  quantity: number;
+  createdAt: string;
+};
+
+export const createItemSchema = z.object({
+  name: z.string().min(1, "Name is required").max(100),
+  quantity: z.coerce.number({ error: "Quantity must be a number" }).int().min(0).max(10000),
+});
+
+export type CreateItemInput = z.input;
+export type CreateItemOutput = z.output;
+
+export const MOCK_ITEMS: Item[] = [
+  { id: "1", name: "Ergonomic chair", quantity: 4, createdAt: "2026-09-01T08:00:00Z" },
+  { id: "2", name: "Mechanical keyboard", quantity: 12, createdAt: "2026-09-02T09:30:00Z" },
+  { id: "3", name: "USB-C dock", quantity: 7, createdAt: "2026-09-03T10:15:00Z" },
+  { id: "4", name: "Monitor arm", quantity: 3, createdAt: "2026-09-04T11:45:00Z" },
+];
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..7679257
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,23 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { getSessionCookie } from "better-auth/cookies";
+
+const PROTECTED_PATHS = ["/protected"];
+
+export function middleware(request: NextRequest) {
+  const { pathname } = request.nextUrl;
+
+  if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) {
+    const sessionCookie = getSessionCookie(request);
+    if (!sessionCookie) {
+      const loginUrl = new URL("/", request.url);
+      loginUrl.searchParams.set("next", pathname);
+      return NextResponse.redirect(loginUrl);
+    }
+  }
+
+  return NextResponse.next();
+}
+
+export const config = {
+  matcher: ["/((?!_next/static|_next/image|favicon.ico|api|.*\\..*).*)"],
+};
diff --git a/package.json b/package.json
index 28caed0..123307b 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "@vitest/coverage-v8": "^5.0.1",
     "eslint": "^9",
     "eslint-config-next": "16.3.5",
     "husky": "^9.1.7",
diff --git a/tsconfig.json b/tsconfig.json
index 1c69c16..239f998 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,8 +30,7 @@
     "**/*.tsx",
     ".next/types/**/*.ts",
     ".next/dev/types/**/*.ts",
-    "**/*.mts",
-    ".next/dev/dev/types/**/*.ts"
+    "**/*.mts"
   ],
   "exclude": ["node_modules"]
 }