- [];
+
+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) => (
+ |
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+ |
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+ |
+ {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"]
}