Stratpoint Engineering

Next.js Fullstack Training

Sign in with your Stratpoint Google account to continue.

Next.js Fullstack
Next.js Fullstack Training
Chapter 5

Code Patterns & References

Study each pattern. When about to write something new, check here first.

Mini-Project 1 — Requirements & Deliverables

What to Build

#Feature / PageTechnical RequirementRequired?
1Home page (app/page.tsx)Server Component. Metadata API: title + description + openGraph.MVP
2About Me page (app/about/page.tsx)Shared layout.tsx with header + nav. loading.tsx skeleton.MVP
3Projects list (app/projects/page.tsx)Async Server Component. Reads from JSON or array. Renders <ProjectCard> grid.MVP
4Individual project (app/projects/[slug]/page.tsx)Dynamic route. await params to get slug. Calls notFound() on invalid slug.MVP
5Contact form (app/contact/page.tsx)Client Component using useActionState() + Server Action. SubmitButton uses useFormStatus(). Shows success/error state.MVP
6error.tsx (at least one route)"use client" boundary. Friendly message + Reset button.MVP
7loading.tsx (at least two routes)Skeleton UI — visually meaningful, not just a spinner.MVP
8All images via next/imageNo raw <img> tags. All images have alt, width, height.MVP
9Tailwind v4 @theme tokenAt least one custom --color-* token in globals.css, used in a component.MVP
10Deployed to VercelLive public URL. Auto-deploys on push to main.MVP
11Blog / Writing sectionReads MDX or JSON per post. Dynamic routes.Stretch
12Dark mode toggleTailwind v4 dark: variant. State via localStorage or system preference.Stretch
13Page transitionsCSS @starting-style or Framer Motion (only after MVP is complete).Stretch

Mandatory Technology Constraints

AreaMust Use (required)Must NOT Use
RoutingNext.js 16 App Router (app/ directory)Pages Router (pages/ directory)
ComponentsServer Components by default; Client only when needed"use client" on every component
StylingTailwind v4 with @import "tailwindcss"tailwind.config.js, inline style={{}} objects
Imagesnext/image with alt, width, heightRaw <img> tags
FormsuseActionState() + Server Actionfetch() from Client Component to an API route
Submit buttonuseFormStatus() for pending stateSeparate useState(loading) to track submission
TypeScriptStrict mode. All props typed with interfacesany type or // @ts-ignore
Package mgrpnpmnpm install, yarn add
Lintingpnpm check (Biome) — must pass with zero errorseslint, 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.

CheckHow It's VerifiedBlocking?
pnpm check passes (zero Biome errors)Run locally before submitting — mentor will not review a failing buildYes — fix before submitting
pnpm tsc --noEmit passes (zero TS errors)Run locally before submitting — mentor will not review a failing buildYes — fix before submitting
pnpm build succeedsRun locally before submitting — mentor will not review a failing buildYes — fix before submitting
Vercel URL is live and publicly accessibleMentor visits the URL in PR descriptionYes
All 10 MVP features are presentMentor clicks through the live siteYes
No raw <img> tags in sourceBiome rule + grep -r "<img" src/Yes
No pages/ directoryMentor checks repo structureYes
Contact form submits without crashingMentor submits the live formYes
loading.tsx is visually meaningfulMentor throttles to 3G in DevToolsNo — 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

FilePurposeServer or Client?
page.tsxThe page UI for this route.Server (default)
layout.tsxShared UI wrapping child routes. Does not re-render on navigation.Server (default)
loading.tsxShown while page data loads. A Suspense fallback.Server
error.tsxShown when something throws. Must add "use client".Client (required)
not-found.tsxShown when notFound() is called. HTTP 404.Server
forbidden.tsxShown when forbidden() is called. HTTP 403. New in Next.js 16.Server
route.tsA backend API endpoint (GET, POST handlers).Server
actions.tsConvention 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

CategoryResourceWhat You'll LearnURL (click or type)
React 19Hooks OverviewAll built-in hooks with examplesreact.dev/reference/react
React 19React 19 Release PostEvery new API explainedreact.dev/blog/2024/12/05/react-19
React 19use()Read Promises and Context during renderreact.dev/reference/react/use
React 19useActionState()Form ↔ Server Action wiringreact.dev/reference/react/useActionState
React 19useFormStatus()Access parent form state in child componentsreact.dev/reference/react-dom/hooks/useFormStatus
React 19useOptimistic()Instant UI before server confirmationreact.dev/reference/react/useOptimistic
Next.js 16Getting StartedFirst-principles App Router intronextjs.org/docs/app/getting-started
Next.js 16Layouts and PagesNested layouts and routing internalsnextjs.org/docs/app/getting-started/layouts-and-pages
Next.js 16File ConventionsEvery special file: page, layout, loading, error, route…nextjs.org/docs/app/api-reference/file-conventions
Next.js 16Partial PrerenderingStatic shell + dynamic streamingnextjs.org/docs/app/guides/partial-prerendering
Next.js 16after() APIDeferred work after response is sentnextjs.org/docs/app/api-reference/functions/after
Next.js 16Image ComponentAutomatic image optimisationnextjs.org/docs/app/api-reference/components/image
Next.js 16Metadata APItitle, description, openGraph for SEOnextjs.org/docs/app/api-reference/functions/generate-metadata
Tailwind v4Installation — Next.jsStep-by-step Tailwind v4 setuptailwindcss.com/docs/installation/framework-guides/nextjs
Tailwind v4Upgrade GuideEvery v3→v4 breaking changetailwindcss.com/docs/upgrade-guide
Tailwind v4@theme DirectiveCustom color and font tokenstailwindcss.com/docs/theme
Shadcn/UI v2Next.js InstallationCLI setup with Tailwind v4ui.shadcn.com/docs/installation/next
TypeScriptTS 5.5 Release NotesInferred type predicates, isolated declarationsdevblogs.microsoft.com/typescript/announcing-typescript-5-5/
VercelNext.js on VercelZero-config deploy, preview URLs, env varsvercel.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 / PageTechnical RequirementRequired?
1Drizzle 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
2Migration files (drizzle/ folder)drizzle-kit generate + drizzle-kit migrate run and SQL files committed. drizzle-kit push alone is NOT acceptable.MVP
3Blog list page (app/blog/page.tsx)Async Server Component. Fetches all posts from Neon. Renders post cards with title, date, and link.MVP
4loading.tsx (app/blog/loading.tsx)Skeleton cards matching the real post list layout.MVP
5Individual post page (app/blog/[slug]/page.tsx)Async Server Component. await params for slug. Calls notFound() if slug not found.MVP
6Comment list on post pageFetches comments for the post. Rendered as a Server Component below post body.MVP
7Comment form (Client Component)useActionState() wired to addComment Server Action. SubmitButton uses useFormStatus(). Shows field-level validation errors.MVP
8addComment Server Action"use server". Zod schema: authorName (min 1, max 80), body (min 10, max 2000). revalidatePath("/blog/[slug]") on success.MVP
9Seed dataAt least 3 blog posts in live DB. Via seed.ts script or Drizzle Studio (document in README).MVP
10Partial Prerenderingexperimental.ppr: true in next.config.ts. Blog list has static shell + <Suspense> around dynamic comment counts.MVP
11Admin new-post pageForm to create posts. Basic password check in Server Action. Redirects to new post.Stretch
12Tag / category filteringtags column added to posts. New migration committed. Filter UI on list page.Stretch
13Comment moderationapproved boolean on comments. Admin toggle via Server Action.Stretch

Mandatory Technology Constraints

AreaMust Use (required)Must NOT Use
DatabaseNeon Postgres with Drizzle ORM v2 (neon-http driver)SQLite, MongoDB, PlanetScale, any other DB
ORMDrizzle ORM v2 (drizzle-orm/neon-http)Prisma, raw SQL strings, any other ORM
Migrationsdrizzle-kit generate + drizzle-kit migrate (SQL files committed)drizzle-kit push alone (no SQL files)
MutationsServer Actions with "use server"fetch() POST from Client Component
ValidationZod safeParse() in every Server ActionDirect formData.get() without schema
Cache bustrevalidatePath() called after every mutationrouter.refresh() as only refresh
FormsuseActionState() for form ↔ Server Action wiringuseState(loading) to track submission
DB accessOnly in Server Components and Server ActionsDrizzle queries inside Client Components
SecretsDATABASE_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

CheckHow It's VerifiedBlocking?
pnpm check passes (zero Biome errors)Run locally before submitting — mentor will not review a failing buildYes — fix before submitting
pnpm tsc --noEmit passesRun locally before submitting — mentor will not review a failing buildYes — fix before submitting
pnpm build succeedsRun locally before submitting — mentor will not review a failing buildYes — fix before submitting
Vercel is live with DATABASE_URL setMentor visits URL; checks Vercel env settingsYes
SQL migration files exist in drizzle/ folderMentor checks repo — push alone is a failYes
At least 3 posts visible at /blogMentor visits live siteYes
Submitting a comment persists after hard refreshMentor submits comment, Ctrl+Shift+RYes
Empty comment form shows validation errorMentor submits blank formYes
No Drizzle queries inside Client Componentsgrep -r "db." src/ — all must be in server filesYes
addComment contains a Zod schemaMentor reads actions.ts — must see z.object({})Yes
revalidatePath called after addCommentMentor reads actions.tsYes
.env.local in .gitignore (no secrets committed)git log --all -- .env.local must be emptyYes
loading.tsx exists for /blog and /blog/[slug]Mentor throttles to 3G in DevToolsNo — feedback only
PPR enabled (experimental.ppr in next.config)Mentor reads next.config.tsNo — 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

FunctionWhat it doesWhen to use
revalidatePath("/url")Purge cached data for a URLIn Server Action after any mutation
revalidateTag("tag")Purge all fetches tagged with this stringMultiple pages sharing the same data
"use cache"Cache an async function resultExpensive DB queries used across many pages
after(async()=>{})Run non-blocking work after responseAnalytics, audit logs, cleanup

Weeks 3–4 Official References

CategoryResourceWhat You'll LearnURL (click or type)
Server ActionsForms & Server ActionsForm handling, validation, pending statenextjs.org/docs/app/guides/forms
Server ActionsData Fetching & CachingHow fetch() caching works in Server Componentsnextjs.org/docs/app/getting-started/data-fetching-and-caching
Server ActionsrevalidatePath()Purge the cache for a URL after mutationsnextjs.org/docs/app/api-reference/functions/revalidatePath
Server ActionsrevalidateTag()Tag-based invalidation with "use cache"nextjs.org/docs/app/api-reference/functions/revalidateTag
Drizzle ORM v2Overview & Quick StartCore concepts, query builder, connection setuporm.drizzle.team/docs/overview
Drizzle ORM v2Schema DeclarationpgTable(), column types, references(), onDeleteorm.drizzle.team/docs/sql-schema-declaration
Drizzle ORM v2Tutorial: Drizzle + NeonFull walkthrough with Neon HTTP driverorm.drizzle.team/docs/tutorials/drizzle-with-neon
Drizzle ORM v2Relational Queriesdb.query.table.findMany({ with:{} })orm.drizzle.team/docs/rqb
Drizzle ORM v2Filters & Operatorseq(), and(), or(), like(), gte()orm.drizzle.team/docs/operators
Drizzle ORM v2Drizzle Kit (Migrations)generate, migrate, push, studio commandsorm.drizzle.team/docs/kit-overview
Neon PostgresQuick StartCreate a project, get DATABASE_URLneon.tech/docs/get-started-with-neon/signing-up
Neon PostgresServerless DriverHTTP driver for Edge Runtimeneon.tech/docs/serverless/serverless-driver
Neon PostgresBranchingDB branch per PR for isolated previewsneon.tech/docs/introduction/branching
ZodGetting Startedz.string(), z.object() — core schemaszod.dev
ZodsafeParse()Validate without throwingzod.dev/?id=safeparse
ZodError Formatting.flatten() for field-level form errorszod.dev/?id=error-handling
Better Auth v1Next.js IntegrationFull setup: install, configure, route handlerbetter-auth.com/docs/integrations/next-js
Better Auth v1Drizzle AdapterdrizzleAdapter() — connect to Drizzle schemabetter-auth.com/docs/adapters/drizzle
Clerk v6Next.js QuickstartClerkProvider, middleware, pre-built UIclerk.com/docs/quickstarts/nextjs
Clerk v6WebhooksSync Clerk users to Neon on sign-upclerk.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.