Code Patterns & References
Study each pattern. When about to write something new, check here first.
Mini-Project 1 — Requirements & Deliverables
What to Build
| # | Feature / Page | Technical Requirement | Required? |
|---|---|---|---|
| 1 | Home page (app/page.tsx) | Server Component. Metadata API: title + description + openGraph. | MVP |
| 2 | About Me page (app/about/page.tsx) | Shared layout.tsx with header + nav. loading.tsx skeleton. | MVP |
| 3 | Projects list (app/projects/page.tsx) | Async Server Component. Reads from JSON or array. Renders <ProjectCard> grid. | MVP |
| 4 | Individual project (app/projects/[slug]/page.tsx) | Dynamic route. await params to get slug. Calls notFound() on invalid slug. | MVP |
| 5 | Contact form (app/contact/page.tsx) | Client Component using useActionState() + Server Action. SubmitButton uses useFormStatus(). Shows success/error state. | MVP |
| 6 | error.tsx (at least one route) | "use client" boundary. Friendly message + Reset button. | MVP |
| 7 | loading.tsx (at least two routes) | Skeleton UI — visually meaningful, not just a spinner. | MVP |
| 8 | All images via next/image | No raw <img> tags. All images have alt, width, height. | MVP |
| 9 | Tailwind v4 @theme token | At least one custom --color-* token in globals.css, used in a component. | MVP |
| 10 | Deployed to Vercel | Live public URL. Auto-deploys on push to main. | MVP |
| 11 | Blog / Writing section | Reads MDX or JSON per post. Dynamic routes. | Stretch |
| 12 | Dark mode toggle | Tailwind v4 dark: variant. State via localStorage or system preference. | Stretch |
| 13 | Page transitions | CSS @starting-style or Framer Motion (only after MVP is complete). | Stretch |
Mandatory Technology Constraints
| Area | Must Use (required) | Must NOT Use |
|---|---|---|
| Routing | Next.js 16 App Router (app/ directory) | Pages Router (pages/ directory) |
| Components | Server Components by default; Client only when needed | "use client" on every component |
| Styling | Tailwind v4 with @import "tailwindcss" | tailwind.config.js, inline style={{}} objects |
| Images | next/image with alt, width, height | Raw <img> tags |
| Forms | useActionState() + Server Action | fetch() from Client Component to an API route |
| Submit button | useFormStatus() for pending state | Separate useState(loading) to track submission |
| TypeScript | Strict mode. All props typed with interfaces | any type or // @ts-ignore |
| Package mgr | pnpm | npm install, yarn add |
| Linting | pnpm check (Biome) — must pass with zero errors | eslint, prettier separately |
What to Submit
Deliverable Checklist
- When your portfolio is live, send your mentor:
- [ ] Live Vercel URL (must be publicly accessible without login)
- [ ] GitHub Repo URL (set to public or shared with mentor)
- [ ] Chapter 10 self-assessment (Weeks 1–2) pasted into your submission message
- [ ] One sentence on what you found hardest and how you solved it
Acceptance Criteria
Your mentor checks each of these when reviewing your submission. Fix any failures before sending.
| Check | How It's Verified | Blocking? |
|---|---|---|
| pnpm check passes (zero Biome errors) | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| pnpm tsc --noEmit passes (zero TS errors) | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| pnpm build succeeds | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| Vercel URL is live and publicly accessible | Mentor visits the URL in PR description | Yes |
| All 10 MVP features are present | Mentor clicks through the live site | Yes |
| No raw <img> tags in source | Biome rule + grep -r "<img" src/ | Yes |
| No pages/ directory | Mentor checks repo structure | Yes |
| Contact form submits without crashing | Mentor submits the live form | Yes |
| loading.tsx is visually meaningful | Mentor throttles to 3G in DevTools | No — feedback only |
5.1 The 4 New React 19 Hooks
Watch Out
These 4 hooks are NEW in React 19. If you see useFormState anywhere — that is the OLD React 18 name. The correct name is useActionState.
use() — Read async data in a Client Component
"use client";
import { use, Suspense } from "react";
function UserCard({ userPromise }) {
const user = use(userPromise); // suspends until resolved
return <h1>Hello, {user.name}</h1>;
}
export default function Page() {
return <Suspense fallback={<p>Loading...</p>}><UserCard userPromise={fetchUser()} /></Suspense>;
}
useActionState() — Wire a form to a Server Action
"use client";
import { useActionState } from "react";
import { createProject } from "@/features/projects/actions";
export function CreateProjectForm() {
const [state, action, isPending] = useActionState(createProject, null);
return (
<form action={action}>
<input name="name" required />
{state?.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>{isPending ? "Creating..." : "Create"}</button>
</form>
);
}
useFormStatus() — Reusable Submit button
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ label = "Submit" }) {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? "Saving..." : label}</button>;
}
useOptimistic() — Instant UI before server responds
"use client";
import { useOptimistic, useTransition } from "react";
import { deleteTask } from "@/features/tasks/actions";
export function TaskList({ initialTasks }) {
const [tasks, setOptimistic] = useOptimistic(
initialTasks,
(current, id) => current.filter(t => t.id !== id)
);
const [, startTransition] = useTransition();
const del = id => startTransition(async () => { setOptimistic(id); await deleteTask(id); });
return tasks.map(t => <div key={t.id}>{t.title}<button onClick={()=>del(t.id)}>Delete</button></div>);
}
5.2 App Router File Conventions
| File | Purpose | Server or Client? |
|---|---|---|
| page.tsx | The page UI for this route. | Server (default) |
| layout.tsx | Shared UI wrapping child routes. Does not re-render on navigation. | Server (default) |
| loading.tsx | Shown while page data loads. A Suspense fallback. | Server |
| error.tsx | Shown when something throws. Must add "use client". | Client (required) |
| not-found.tsx | Shown when notFound() is called. HTTP 404. | Server |
| forbidden.tsx | Shown when forbidden() is called. HTTP 403. New in Next.js 16. | Server |
| route.ts | A backend API endpoint (GET, POST handlers). | Server |
| actions.ts | Convention file for Server Actions. | Server ("use server") |
5.3 params are Promises in Next.js 16
Watch Out
BREAKING CHANGE from Next.js 14: params and searchParams are now Promises. Always await them.
// WRONG (Next.js 14 pattern — breaks in v16):
export default function Page({ params }: { params: { id: string } }) {
const { id } = params; // TypeScript error
}
// CORRECT (Next.js 16):
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ProjectBoard id={id} />;
}
5.4 Tailwind v4 — Key Differences from v3
| In v3 (old) | In v4 (new — use this) |
|---|---|
| tailwind.config.js with theme.extend | @theme block in globals.css |
| import "tailwindcss/base" etc. | @import "tailwindcss" (single line) |
| module.exports = { ... } | No config file needed at all |
| Custom colors in config object | --color-brand: oklch(...) inside @theme {} |
/* globals.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.18 250);
--font-sans: "Geist", sans-serif;
}
<button className="bg-brand text-white">Click</button>
Weeks 1–2 Official References
| Category | Resource | What You'll Learn | URL (click or type) |
|---|---|---|---|
| React 19 | Hooks Overview | All built-in hooks with examples | react.dev/reference/react |
| React 19 | React 19 Release Post | Every new API explained | react.dev/blog/2024/12/05/react-19 |
| React 19 | use() | Read Promises and Context during render | react.dev/reference/react/use |
| React 19 | useActionState() | Form ↔ Server Action wiring | react.dev/reference/react/useActionState |
| React 19 | useFormStatus() | Access parent form state in child components | react.dev/reference/react-dom/hooks/useFormStatus |
| React 19 | useOptimistic() | Instant UI before server confirmation | react.dev/reference/react/useOptimistic |
| Next.js 16 | Getting Started | First-principles App Router intro | nextjs.org/docs/app/getting-started |
| Next.js 16 | Layouts and Pages | Nested layouts and routing internals | nextjs.org/docs/app/getting-started/layouts-and-pages |
| Next.js 16 | File Conventions | Every special file: page, layout, loading, error, route… | nextjs.org/docs/app/api-reference/file-conventions |
| Next.js 16 | Partial Prerendering | Static shell + dynamic streaming | nextjs.org/docs/app/guides/partial-prerendering |
| Next.js 16 | after() API | Deferred work after response is sent | nextjs.org/docs/app/api-reference/functions/after |
| Next.js 16 | Image Component | Automatic image optimisation | nextjs.org/docs/app/api-reference/components/image |
| Next.js 16 | Metadata API | title, description, openGraph for SEO | nextjs.org/docs/app/api-reference/functions/generate-metadata |
| Tailwind v4 | Installation — Next.js | Step-by-step Tailwind v4 setup | tailwindcss.com/docs/installation/framework-guides/nextjs |
| Tailwind v4 | Upgrade Guide | Every v3→v4 breaking change | tailwindcss.com/docs/upgrade-guide |
| Tailwind v4 | @theme Directive | Custom color and font tokens | tailwindcss.com/docs/theme |
| Shadcn/UI v2 | Next.js Installation | CLI setup with Tailwind v4 | ui.shadcn.com/docs/installation/next |
| TypeScript | TS 5.5 Release Notes | Inferred type predicates, isolated declarations | devblogs.microsoft.com/typescript/announcing-typescript-5-5/ |
| Vercel | Next.js on Vercel | Zero-config deploy, preview URLs, env vars | vercel.com/docs/frameworks/nextjs |
Pro Tip
Reading order Week 1: react.dev hooks reference → nextjs.org App Router getting-started.
Tailwind: read the upgrade guide FIRST if you have any v3 experience.
Add components via CLI: pnpm dlx shadcn@latest add button
Mini-Project 2 — Requirements & Deliverables
What to Build
| # | Feature / Page | Technical Requirement | Required? |
|---|---|---|---|
| 1 | Drizzle schema file (lib/db/schema.ts) | Defines posts table (id, title, slug, body, createdAt) and comments table (id, postId FK, authorName, body, createdAt) with .onDelete("cascade"). | MVP |
| 2 | Migration files (drizzle/ folder) | drizzle-kit generate + drizzle-kit migrate run and SQL files committed. drizzle-kit push alone is NOT acceptable. | MVP |
| 3 | Blog list page (app/blog/page.tsx) | Async Server Component. Fetches all posts from Neon. Renders post cards with title, date, and link. | MVP |
| 4 | loading.tsx (app/blog/loading.tsx) | Skeleton cards matching the real post list layout. | MVP |
| 5 | Individual post page (app/blog/[slug]/page.tsx) | Async Server Component. await params for slug. Calls notFound() if slug not found. | MVP |
| 6 | Comment list on post page | Fetches comments for the post. Rendered as a Server Component below post body. | MVP |
| 7 | Comment form (Client Component) | useActionState() wired to addComment Server Action. SubmitButton uses useFormStatus(). Shows field-level validation errors. | MVP |
| 8 | addComment Server Action | "use server". Zod schema: authorName (min 1, max 80), body (min 10, max 2000). revalidatePath("/blog/[slug]") on success. | MVP |
| 9 | Seed data | At least 3 blog posts in live DB. Via seed.ts script or Drizzle Studio (document in README). | MVP |
| 10 | Partial Prerendering | experimental.ppr: true in next.config.ts. Blog list has static shell + <Suspense> around dynamic comment counts. | MVP |
| 11 | Admin new-post page | Form to create posts. Basic password check in Server Action. Redirects to new post. | Stretch |
| 12 | Tag / category filtering | tags column added to posts. New migration committed. Filter UI on list page. | Stretch |
| 13 | Comment moderation | approved boolean on comments. Admin toggle via Server Action. | Stretch |
Mandatory Technology Constraints
| Area | Must Use (required) | Must NOT Use |
|---|---|---|
| Database | Neon Postgres with Drizzle ORM v2 (neon-http driver) | SQLite, MongoDB, PlanetScale, any other DB |
| ORM | Drizzle ORM v2 (drizzle-orm/neon-http) | Prisma, raw SQL strings, any other ORM |
| Migrations | drizzle-kit generate + drizzle-kit migrate (SQL files committed) | drizzle-kit push alone (no SQL files) |
| Mutations | Server Actions with "use server" | fetch() POST from Client Component |
| Validation | Zod safeParse() in every Server Action | Direct formData.get() without schema |
| Cache bust | revalidatePath() called after every mutation | router.refresh() as only refresh |
| Forms | useActionState() for form ↔ Server Action wiring | useState(loading) to track submission |
| DB access | Only in Server Components and Server Actions | Drizzle queries inside Client Components |
| Secrets | DATABASE_URL in .env.local (never committed) | Hard-coded connection strings in source |
Required Database Schema
Your schema.ts must match this minimum. You may add columns but not remove required ones.
// lib/db/schema.ts
import { pgTable, text, uuid, timestamp } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
body: text("body").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const comments = pgTable("comments", {
id: uuid("id").primaryKey().defaultRandom(),
postId: uuid("post_id").references(() => posts.id, { onDelete: "cascade" }).notNull(),
authorName: text("author_name").notNull(),
body: text("body").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const postsRelations = relations(posts, ({ many }) => ({ comments: many(comments) }));
export const commentsRelations = relations(comments, ({ one }) => ({ post: one(posts, { fields: [comments.postId], references: [posts.id] }) }));
What to Submit
Deliverable Checklist
- When your blog is live, send your mentor:
- [ ] Live Vercel URL with at least 3 blog posts visible at /blog
- [ ] GitHub Repo URL — schema.ts, drizzle/ folder, and actions.ts must all be present
- [ ] README updated: local setup steps, how to run migrations, how to seed posts
- [ ] Demo recording or GIF: submit a comment on a live post, show it persists after refresh
- [ ] Chapter 10 self-assessment (Weeks 3–4) completed — paste or screenshot into your submission message
Acceptance Criteria
| Check | How It's Verified | Blocking? |
|---|---|---|
| pnpm check passes (zero Biome errors) | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| pnpm tsc --noEmit passes | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| pnpm build succeeds | Run locally before submitting — mentor will not review a failing build | Yes — fix before submitting |
| Vercel is live with DATABASE_URL set | Mentor visits URL; checks Vercel env settings | Yes |
| SQL migration files exist in drizzle/ folder | Mentor checks repo — push alone is a fail | Yes |
| At least 3 posts visible at /blog | Mentor visits live site | Yes |
| Submitting a comment persists after hard refresh | Mentor submits comment, Ctrl+Shift+R | Yes |
| Empty comment form shows validation error | Mentor submits blank form | Yes |
| No Drizzle queries inside Client Components | grep -r "db." src/ — all must be in server files | Yes |
| addComment contains a Zod schema | Mentor reads actions.ts — must see z.object({}) | Yes |
| revalidatePath called after addComment | Mentor reads actions.ts | Yes |
| .env.local in .gitignore (no secrets committed) | git log --all -- .env.local must be empty | Yes |
| loading.tsx exists for /blog and /blog/[slug] | Mentor throttles to 3G in DevTools | No — feedback only |
| PPR enabled (experimental.ppr in next.config) | Mentor reads next.config.ts | No — feedback only |
5.5 Server Actions — The Full Template
Every mutation in the capstone uses this structure. Memorise it.
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { tasks } from "@/lib/db/schema";
import { revalidatePath } from "next/cache";
const schema = z.object({
title: z.string().min(1,"Title required").max(200),
listId: z.string().uuid(),
});
export async function createTask(_: unknown, formData: FormData) {
const parsed = schema.safeParse({
title: formData.get("title"), listId: formData.get("listId"),
});
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors.title?.[0] };
await db.insert(tasks).values(parsed.data);
revalidatePath("/board");
return { success: true };
}
Pro Tip
The signature (_: unknown, formData: FormData) is required when using useActionState().
Always return a plain serialisable object — not a class instance, not a Response.
5.6 Drizzle ORM v2 — CRUD
// lib/db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
// INSERT
const [task] = await db.insert(tasks).values({ title, listId }).returning();
// SELECT (filtered)
import { eq, and, desc } from "drizzle-orm";
const rows = await db.select().from(tasks).where(eq(tasks.listId, id)).orderBy(desc(tasks.createdAt));
// RELATIONAL (with joins)
const board = await db.query.lists.findMany({ with: { tasks: true } });
// UPDATE
await db.update(tasks).set({ title: "New" }).where(eq(tasks.id, taskId));
// DELETE
await db.delete(tasks).where(eq(tasks.id, taskId));
5.7 Cache Invalidation — The 4 Primitives
| Function | What it does | When to use |
|---|---|---|
| revalidatePath("/url") | Purge cached data for a URL | In Server Action after any mutation |
| revalidateTag("tag") | Purge all fetches tagged with this string | Multiple pages sharing the same data |
| "use cache" | Cache an async function result | Expensive DB queries used across many pages |
| after(async()=>{}) | Run non-blocking work after response | Analytics, audit logs, cleanup |
Weeks 3–4 Official References
| Category | Resource | What You'll Learn | URL (click or type) |
|---|---|---|---|
| Server Actions | Forms & Server Actions | Form handling, validation, pending state | nextjs.org/docs/app/guides/forms |
| Server Actions | Data Fetching & Caching | How fetch() caching works in Server Components | nextjs.org/docs/app/getting-started/data-fetching-and-caching |
| Server Actions | revalidatePath() | Purge the cache for a URL after mutations | nextjs.org/docs/app/api-reference/functions/revalidatePath |
| Server Actions | revalidateTag() | Tag-based invalidation with "use cache" | nextjs.org/docs/app/api-reference/functions/revalidateTag |
| Drizzle ORM v2 | Overview & Quick Start | Core concepts, query builder, connection setup | orm.drizzle.team/docs/overview |
| Drizzle ORM v2 | Schema Declaration | pgTable(), column types, references(), onDelete | orm.drizzle.team/docs/sql-schema-declaration |
| Drizzle ORM v2 | Tutorial: Drizzle + Neon | Full walkthrough with Neon HTTP driver | orm.drizzle.team/docs/tutorials/drizzle-with-neon |
| Drizzle ORM v2 | Relational Queries | db.query.table.findMany({ with:{} }) | orm.drizzle.team/docs/rqb |
| Drizzle ORM v2 | Filters & Operators | eq(), and(), or(), like(), gte() | orm.drizzle.team/docs/operators |
| Drizzle ORM v2 | Drizzle Kit (Migrations) | generate, migrate, push, studio commands | orm.drizzle.team/docs/kit-overview |
| Neon Postgres | Quick Start | Create a project, get DATABASE_URL | neon.tech/docs/get-started-with-neon/signing-up |
| Neon Postgres | Serverless Driver | HTTP driver for Edge Runtime | neon.tech/docs/serverless/serverless-driver |
| Neon Postgres | Branching | DB branch per PR for isolated previews | neon.tech/docs/introduction/branching |
| Zod | Getting Started | z.string(), z.object() — core schemas | zod.dev |
| Zod | safeParse() | Validate without throwing | zod.dev/?id=safeparse |
| Zod | Error Formatting | .flatten() for field-level form errors | zod.dev/?id=error-handling |
| Better Auth v1 | Next.js Integration | Full setup: install, configure, route handler | better-auth.com/docs/integrations/next-js |
| Better Auth v1 | Drizzle Adapter | drizzleAdapter() — connect to Drizzle schema | better-auth.com/docs/adapters/drizzle |
| Clerk v6 | Next.js Quickstart | ClerkProvider, middleware, pre-built UI | clerk.com/docs/quickstarts/nextjs |
| Clerk v6 | Webhooks | Sync Clerk users to Neon on sign-up | clerk.com/docs/integrations/webhooks/sync-data |
Pro Tip
Drizzle: run pnpm db:studio after schema changes to visually confirm migration applied.
Zod: use z.string().trim().min(1) — rejects whitespace-only strings.
Better Auth: create /api/auth/[...all]/route.ts manually — it does not auto-generate.