Skip to content
back to blog

30 minEngineering

Auditing my own portfolio against Next.js 16: what I shipped, what I skipped, and why

I audited my own portfolio against the Next.js 16 upgrade guide line by line. Here's what the site uses, what it deliberately leaves out, and the v16 changes I caught only because I read the release notes instead of a tutorial.

I built this site from a clean App Router start on Next.js 16. Then I sat down and audited it the way a Vercel reviewer would. The interesting question was never "did I use every primitive?" It was "did I make the right call for each one, and can I defend the omissions out loud?"

This post is the audit. The structure follows the spine that ended up mattering: what I used, what I deliberately did not use, what v16 caught me on after I read the upgrade guide, and the senior-engineer signals I am still trying to earn.

The aim is to write the post I wish I had read when I was prepping for a Next.js technical interview, which is roughly where I am right now.

The short version: best practices this site actually follows

If you want the headline before the deep dive, here are the calls that matter most:

  • Server components by default. About 60% of the component files are server-rendered. 'use client' lives at the leaves (animation hooks, event handlers, browser APIs), never at the root of a page tree.
  • SSG-first with explicit exceptions. Every page that can be prerendered at build time is. ISR is opted into on one route where the content has a real expiry. Dynamic rendering is reserved for genuinely per-request work (RAG endpoint, OG image generator, RSS feed).
  • dynamicParams = false on every finite-slug route. Blog and portfolio slug routes return 404 for anything not in the build-time list. Eliminates a slug-fuzzing surface.
  • generateStaticParams enumerates the URL space at build. Combined with the line above, Next knows the full set of pages before any user request lands.
  • Metadata API is the spine, not a checkbox. Per-post generateMetadata, a single buildMetadata helper, dynamic OG images via next/og, RSS feed handler, sitemap, robots, and JSON-LD on every page that has one.
  • Type safety from the route up. npx next typegen generates PageProps<'/blog/[slug]'> so route params are typed from the actual filesystem instead of hand-written. Typecheck is wired to run typegen first so it self-heals on clean checkouts.
  • Strict TypeScript with noUncheckedIndexedAccess. Catches the array-index undefined bugs that bite half a year after they ship.
  • Tailwind v4 CSS-first. No tailwind.config.js. Design tokens live as CSS custom properties under @theme in globals.css where they belong.
  • Turbopack default. v16 stable for both dev and build. No flag needed; the explicit --turbopack script flag has been removed.
  • Leaf-isolated dynamic imports. Heavy animation modules load behind next/dynamic with { ssr: false } so they ship in their own chunks after hydration instead of bloating the initial route bundle.
  • Bundle analyzer wired behind a flag. pnpm analyze runs a production build with @next/bundle-analyzer and emits an interactive treemap. The v16 build output dropped its "First Load JS" column for RSC-correctness reasons, so the analyzer is the way to keep client bundle weight honest.
  • Defensive HTTP headers in next.config.ts. X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy denying camera and microphone and geolocation, plus immutable cache headers on /fonts/*.

The rest of the post walks each of these out, plus the primitives I deliberately did not use.

Stack snapshot

Next.js 16.0 on the App Router. React 19 stable. TypeScript strict mode with noUncheckedIndexedAccess and noImplicitOverride. Tailwind v4 with CSS-first config in globals.css. Three font families via next/font/google plus a manually preloaded Migra italic for the display tier. Turbopack as the default bundler for both dev and build (one of the v16 things I had to remove a redundant flag for). Vercel for hosting. MDX for content via @next/mdx. No Pages Router code anywhere; the site started on App Router from day one.

That last detail matters. The migration question (Pages to App) is the most common Next.js interview prompt I see, and I have nothing interesting to say about it because I never had to do it. What I can speak to is reasoning about App Router primitives from a clean start, which I think is actually the more useful skill.

Server components are the default, and I treated them that way

Of about 85 component and route files in the repo, 35 have 'use client' at the top. The other 50 are server components by default. Every client component earns its directive on one of five grounds: it uses hooks (useState, useEffect, usePathname), it attaches event listeners, it touches a browser API (canvas, WebGL, scroll position), it provides React context (Lenis), or it is an error boundary.

The pattern I worked hard to keep is "client components are leaves, not branches." The Hero component at src/components/Hero.tsx is a client component because it runs an opacity animation on mount. But it does not wrap the whole homepage. The page itself, at src/app/page.tsx, stays a server component. It reads from the file system (blog posts, projects), renders the static layout, and embeds the Hero as one leaf among several.

The same logic governs heavier pieces. The hero canvas effect (HeroLogoRain), the smooth-scroll provider (LenisProvider), and the magnetic cursor (Cursor) are all imported via next/dynamic with { ssr: false }. They are browser-only by definition (canvas, scroll APIs, mouse tracking), and they are not on the LCP critical path, so deferring them keeps the initial HTML lean.

The interview-flavored test for this is simple. Pick any page, look at the route file, and ask: how much of this tree could in principle render with JSON.stringify of plain data instead of a React-DOM hydration walk? On this site the answer for every route is "most of it." That is the win.

File architecture and routing: what the app/ tree actually looks like

The full top-level layout under src/app/:

src/app/
├── layout.tsx          single root layout, server component
├── page.tsx            home, server component
├── globals.css         tailwind v4 import + design tokens
├── error.tsx           route-level error boundary, client
├── global-error.tsx    root error boundary (wraps html + body)
├── not-found.tsx       custom 404
├── icon.tsx            runtime-generated favicon
├── apple-icon.tsx      runtime-generated apple touch icon
├── sitemap.ts          dynamic sitemap
├── robots.ts           robots.txt generator
├── about/page.tsx
├── contact/page.tsx
├── demos/page.tsx
├── resume/page.tsx     ISR, revalidate: 3600
├── blog/
│   ├── page.tsx        index, reads searchParams (tag, page)
│   └── [slug]/page.tsx dynamic segment, dynamicParams: false
├── portfolio/
│   ├── page.tsx        index, reads searchParams (tab)
│   └── [slug]/page.tsx dynamic segment, dynamicParams: false
├── api/kev-o/route.ts  RAG endpoint, Node runtime, streaming
├── feed.xml/route.ts   RSS handler with Cache-Control
└── og/route.tsx        dynamic OG image via ImageResponse

The shape under it: every URL segment is a folder, every page in that segment is a page.tsx. The folder structure is the URL structure. There is one root layout.tsx and no nested layouts. Each dynamic segment uses square-bracket folders ([slug]). API endpoints are route.ts files under app/api/ colocated with their domain.

The advantages of this flat shape, on this site specifically:

The site has one visual chrome (header, footer, grain layer, command palette, cursor) that appears on every page. There is no logged-in vs logged-out shell, no marketing vs admin split, no settings sub-app. Adding nested layouts would require duplicating the chrome or adding pass-through layouts that exist only to re-export children. Either path is ceremony. One root layout is the right answer.

Colocating API handlers under app/api/ instead of a parallel pages/api/ tree (the old way) lets the route handler import server-only utilities (lib/seo.ts, lib/content.ts, the MDX corpus builder) without indirection. The Kev-O route handler reads the corpus JSON that the prebuild step writes under public/kev-o-corpus.json. The file path lookup is dead simple because there is one source tree.

The dynamic segments ([slug]) carry both generateStaticParams and dynamicParams = false. That combo lets Next prerender all 11 blog posts and 18 portfolio entries at build time, and 404 anything else. The list of valid URLs is decided by what is on disk, not by request-time logic.

App Router primitives the site uses

A complete enumeration:

  • Single root layout.tsx. Wraps every page. Sets <html lang="en">, preloads Migra italic with <link rel="preload" fetchPriority="high">, injects the RSS <link rel="alternate">, drops two JSON-LD blocks (Person, Website) into <head>, and renders the chrome (skip-to-content link, grain, header, footer, command palette) around {children}.
  • page.tsx. Every visible URL has one. They are server components by default. The two dynamic segments (/blog/[slug]/page.tsx and /portfolio/[slug]/page.tsx) use the PageProps<'/...'> typegen helper for their params.
  • Dynamic segments ([slug]). Both content surfaces (blog, portfolio) use a single dynamic segment with generateStaticParams + dynamicParams = false. The pages are prerendered; the URL space is finite and locked.
  • searchParams for filter state. /blog?tag=ai&page=2 and /portfolio?tab=demos read searchParams (now a Promise in v16, awaited per the upgrade guide) for filter and pagination state. This is the right call over a dynamic segment because the filter is a view of one underlying list, not a separate page. Search params change without rebuilding the route.
  • error.tsx. A client component (Next requires this) that renders a recovery UI when a server component throws below it. Shows a "something broke" message with a reset() button that re-runs the segment. Scoped to everything under app/ except the root layout.
  • global-error.tsx. A client component that catches errors in the root layout itself, including hydration failures. Renders its own <html> and <body> because the root layout has crashed. This is the last line of defense.
  • not-found.tsx. A custom 404 page rendered when notFound() is called or when a route segment does not exist. The blog and portfolio slug routes call notFound() when a slug is missing, which now (with dynamicParams = false) happens at build-time enumeration rather than at request time.
  • route.ts and route.tsx. Six route handlers total: the Kev-O RAG endpoint (streaming, rate-limited), an admin endpoint, a health probe, an admin reset, the RSS feed handler, and the dynamic OG image generator. All declare export const runtime = 'nodejs' explicitly so there is no ambiguity about where they run.
  • sitemap.ts and robots.ts. Special filenames Next recognizes. The sitemap reads the same getPosts() and projects data the rest of the site uses, so the URL list cannot drift from the actual content.
  • icon.tsx and apple-icon.tsx. Runtime-generated favicons via ImageResponse. Lets the brand mark stay consistent without checking PNG assets into the repo.
  • generateMetadata on every page or layout that needs per-route metadata. Per-post overrides for ogTitle, ogSubtitle, and ogEyebrow.
  • generateStaticParams on both dynamic segments. Enumerates the URL space at build.

App Router primitives the site does not use, and why

  • Nested layout.tsx files. Not used. The site has one shell. A nested layout would either duplicate the chrome or pass-through children. Adding one as decoration is the kind of move that signals "I memorized the API" rather than "I made a decision."
  • template.tsx. Not used. Templates differ from layouts in that they re-mount on every navigation instead of persisting. They are useful when a page needs to reset state between routes (a multi-step form, an animation that should restart). Nothing on this site needs that.
  • loading.tsx. Not used. Same reasoning as the Suspense section below: no route on this site has real wait time on the server, so a loading state would be theatre that makes pages feel slower.
  • default.tsx. Required only for parallel routes. The site has no parallel routes. Not needed.
  • Catch-all routes ([...slug]). Not used. The blog and portfolio URL spaces are flat (/blog/foo, not /blog/2026/04/foo). A catch-all would let the URL space grow without code changes, which is the opposite of what dynamicParams = false is doing. The two design choices point in opposite directions, and locking the URL space is the right answer for a portfolio.
  • Optional catch-all routes ([[...slug]]). Not used. Same reasoning as catch-all, plus the optional variant is for cases where the segment may be empty. No use case here.
  • Private folders (_name). Not used. Private folders are a way to put utilities inside app/ that should not be picked up as routes. The site uses src/lib/ and src/components/ for that, which is the more common pattern and lives outside app/ entirely. The private-folder convention is mostly useful for monorepos or for projects that want everything under app/.
  • Route groups ((name)). Not used. Route groups let you share a layout across a subset of routes without affecting URLs. With one root layout and no shell variations, there is nothing to group.
  • Parallel routes (@slot). Not used. Closest call was the /portfolio tabs (work, demos, OSS). I went with searchParams instead because the tabs are filter views on one list, not three URL-addressable panels.
  • Intercepting routes ((.)foo, (..)foo). Not used. The Instagram-style modal pattern is the canonical use case (click a thumbnail to open a modal that shows the same URL as the standalone page). No need here.

The pattern across the "not used" list is the same: every primitive solves a specific problem. If the project does not have that problem, adopting the primitive adds complexity without adding capability. Saying so out loud is the senior signal.

Error boundaries: three layers, deliberately

The error handling story spans three files, in order from innermost to outermost:

notFound() calls inside page.tsx handlers trigger the nearest not-found.tsx. That covers "this content does not exist" (a slug that was not in generateStaticParams, a search that returned zero results that should 404 instead of empty).

error.tsx catches runtime errors thrown inside server components below the root layout. It is a client component because the recovery UI needs interactivity (the reset() button). It renders inside the root layout, so the header and footer still show; only the page content gets replaced.

global-error.tsx catches errors in the root layout itself, including hydration failures. It renders its own <html> and <body> because the chrome it would normally rely on is what crashed. This is the bottom of the stack.

The three layers are not interchangeable. Putting your error UI in only error.tsx means a broken root layout shows a blank page. Putting it in only global-error.tsx means every recoverable error in a page replaces the entire shell. Both files exist on this site for that reason.

Rendering modes: SSG-first, one ISR exception, dynamic only where it has to be

The route table from next build for this site reads:

○  /                           static
○  /about                      static
○  /contact                    static
○  /demos                      static
●  /blog/[slug]                SSG (11 paths)
●  /portfolio/[slug]           SSG (18 paths)
○  /resume                     ISR (revalidate: 1h)
ƒ  /api/kev-o                  dynamic (Node runtime)
ƒ  /og                         dynamic (ImageResponse)
ƒ  /feed.xml                   dynamic

The static pages are static because their content is checked into the repo. The dynamic segment routes (/blog/[slug], /portfolio/[slug]) use generateStaticParams to enumerate the slugs at build time, and I added export const dynamicParams = false while writing this post. That last setting is the senior signal. It tells Next that any slug not in the build-time list returns 404 instead of being lazily rendered at request time. It eliminates a class of fuzzing surfaces (poking at random /blog/foo-bar) and it makes the route deterministic. I would expect a Vercel reviewer to ask why dynamicParams = false is not on every route that has a finite slug set. It is a one-line change with no downside.

The one ISR route is /resume. The resume PDF is generated by Puppeteer in a postbuild step, but the resume page itself displays metadata about certifications. Some of that metadata has expiry dates. Once an hour the page revalidates so the rendered "expires in N months" copy stays accurate without needing a redeploy. The route declares export const revalidate = 3600.

The dynamic route handlers all serve traffic that cannot be precomputed. /api/kev-o is the RAG endpoint that streams Claude responses against an MDX corpus. /og is the dynamic Open Graph image generator (Satori under the hood via next/og). /feed.xml is the RSS feed, which I made dynamic on purpose so it can read whatever the latest published post is without a rebuild.

What I want to flag here is what is not on the list. There is no route using PPR (Partial Prerendering) or Cache Components. I considered enabling cacheComponents: true. I decided against it. The mental model for Cache Components is built around the case where one route mixes a fast static shell with slow dynamic holes (a product page where layout is cached but price is per-user, a dashboard with a cached layout and a live data widget). This site does not have that pattern. Every page is either fully static or fully dynamic. Adding the Cache Components opt-in would introduce ceremony with no measurable benefit, and v16 explicitly warns that PPR works differently from the v15 canary, so the cost of opting in is real. The right call was to leave it off and write that decision down. If I ever add a route with mixed static and per-user data, that is when Cache Components earns its place.

Metadata API: this is where the site invests

If I had to point to one area where a Vercel reviewer should expect depth, it is the metadata surface. The site ships:

  • generateMetadata on every dynamic route, with per-post overrides for ogTitle, ogSubtitle, and ogEyebrow (so a long post title renders as a short OG title on LinkedIn)
  • A sitemap.ts route that emits every static and dynamic URL with lastModified derived from frontmatter
  • A robots.ts that allows all, disallows /api/, and links the sitemap
  • A feed.xml route handler that emits RSS 2.0 with Cache-Control: public, max-age=600, s-maxage=3600
  • A dynamic OG image route at /og using ImageResponse from next/og, which renders Migra italic on a noise-textured canvas with custom fonts loaded from disk
  • JSON-LD structured data in the root layout (Person, Website) and per page (Breadcrumb, Article, CreativeWork)
  • icon.tsx and apple-icon.tsx that generate favicons at runtime so the brand mark stays consistent without checking PNG assets into the repo

The mistake I see in a lot of portfolio repos is treating metadata as a checkbox. Either there is no generateMetadata and the home page title leaks across every route, or there is one static metadata export per page with the same boilerplate copied around. The senior pattern is: define buildMetadata as a single helper (in src/lib/seo.ts here), and have every page call it with just the fields that actually vary. The helper handles the absolute URL, the OG image URL with query parameters, the canonical, and the JSON-LD shape. The page declares intent, not boilerplate.

One subtle v16 thing on metadata. generateMetadata's params and searchParams are Promises now. The compatibility shim for sync access from v15 is fully removed. If you upgrade from 14 or 15 and you have not run npx next typegen, you will get type errors on every dynamic route. Running typegen produces a PageProps<'/blog/[slug]'> helper that types the route's params correctly without hand-written types. I converted both dynamic routes to use PageProps<'/blog/[slug]'> and PageProps<'/portfolio/[slug]'> while writing this post. The types are now sourced from the actual route structure instead of duplicated.

Streaming, Suspense, and loading.tsx: deliberately absent

There are no <Suspense> boundaries in the codebase. There are no loading.tsx files. This was a deliberate call and it is one of the easier ones to defend in an interview.

<Suspense> and loading.tsx are streaming primitives. They earn their place when a route has real wait time on the server (a DB query, a slow third-party API, an LLM call) and you want to flush the parts of the page that are ready while the slow parts resolve in the background. The streaming model is genuinely beautiful, and PPR builds on top of it.

This site does not have those latencies. Every page either reads from the file system (sub-millisecond on a Vercel edge node) or it serves prerendered HTML. There is no point flushing an empty shell with a skeleton when the full page is already on the CDN. Adding a loading.tsx would actually make the page feel slower because users would see a flash of the loading state.

The honest version of this answer in an interview goes: "I would add a <Suspense> boundary the first time I introduced a server component that called a slow API. Right now the slowest server-side work on the site is a Promise.all([getPosts(), getAllTags()]) that completes in single-digit milliseconds. The boundary would be theatre."

Server actions, middleware, and proxy: also deliberately absent

There are no server actions in the repo ('use server' does not appear). There is no middleware.ts, and consequently no proxy.ts either (that is the v16 rename, which I will get to).

Server actions are the right tool for mutations triggered from your own UI. This site has no mutations. The contact page is a mailto: link. The Kev-O chat interface posts to a route handler at /api/kev-o because it needs streaming and external rate-limit checks that route handlers handle more cleanly. If I ever add a form (a guestbook, a newsletter signup), I will use a server action. Until then, adding one as decoration would be the same kind of mistake as adding a loading.tsx.

Middleware was harder to think through. The v16 release renames middleware.ts to proxy.ts and forces it to run on the Node.js runtime instead of the Edge runtime. That is a significant change. The reasoning in the release notes is that middleware was being used for too many things, and the "proxy" framing makes the file's actual job clearer: it sits in front of every matching request and decides whether to redirect, rewrite, or pass through. If you need Edge runtime auth checks, the upgrade guide says to keep middleware.ts for now (deprecated, but functional) and wait for the next minor release.

This site has no auth, no A/B testing, no locale negotiation (the global bilingual rule is explicitly overridden for this project, per ADR-018), and no per-request decisions to make. Adding a proxy would be code that runs on every page load and does nothing. So there is no proxy, and that is a deliberate choice rather than an oversight.

next/image, next/font, next/script: the standard kit, used correctly

There is no raw <img> tag in the codebase. Two pages use next/image: the about page (headshot) and the hero on the home page. Both pass explicit sizes attributes for the responsive srcset. The hero image is the LCP element on the home page and carries priority, which is still the correct prop in Next 16 (a reference doc I cross-checked claimed it had been renamed to preload, but the actual v16 upgrade guide makes no such mention; do not trust secondary sources without verifying).

Fonts come through next/font/google for Space Grotesk, Geist Mono, and Instrument Serif. All three use display: 'swap' so the page renders immediately with the fallback font and reflows when the web font loads. The variables (--font-space-grotesk, --font-geist-mono, --font-instrument-serif) are exposed as CSS custom properties and consumed in globals.css.

The display font (Migra italic) is not on Google Fonts. It is a trial file licensed for evaluation, served from public/fonts/migra/. The font is preloaded explicitly in the root layout with <link rel="preload" href="/fonts/migra/..." as="font" type="font/woff2" crossOrigin="anonymous" fetchPriority="high">. This is one of the few places where I dropped to a raw <link> tag because next/font/local does not handle proprietary trial files cleanly. The trade-off is conscious.

The v16 image config changes worth noting (verified against the upgrade guide): images.minimumCacheTTL default went from 60 seconds to 4 hours. images.qualities went from "any value 1-100 allowed" to "only [75] allowed by default" with non-75 values coerced to the nearest allowed quality. images.domains is deprecated in favor of remotePatterns. I checked my code: no quality props anywhere (so the coerce-to-75 change is a non-issue), images.domains is not used (already on remotePatterns), and I am happy with the longer cache TTL.

Bundle optimization: leaf isolation plus dynamic imports

The v16 release removed the size and First Load JS columns from the next build output because the team felt the numbers were inaccurate in RSC architectures, and they recommend Chrome Lighthouse or Vercel Analytics instead. That is fine advice for production monitoring but it does not replace a static analyzer for "did I just balloon the home page bundle by adding a new dependency."

So @next/bundle-analyzer is wired into next.config.ts behind an ANALYZE=true flag, and pnpm analyze runs a full production build that emits an interactive treemap to .next/analyze/. The flag means the analyzer never runs on regular builds (no overhead), but it is one command away whenever a PR feels like it might have ballooned a route.

The static analyzer is half of the story. The other half is the leaf-isolation pattern enforced at code-review time. Any new 'use client' directive has to justify why the whole component (not just the interactive part) needs to be on the client. The heavy animation modules (HeroLogoRain, LostSignal, ChromaticField) are all behind next/dynamic with { ssr: false }, which means they ship in their own chunks loaded only after hydration. The MDX rendering happens entirely on the server. The MDX runtime is not in the client bundle.

What v16 caught me on, after I read the upgrade guide

These are the actual changes I made to this codebase while writing this post:

  1. Added export const dynamicParams = false to both dynamic routes. This is a senior signal that has nothing to do with v16 specifically, but writing the post is what made me realize I had not done it.
  2. Converted params types to use the new PageProps<'/...'> helper from npx next typegen. The helper has been around since 15.5 but the upgrade guide is what pointed me at it.
  3. Removed the --turbopack flag from the dev script in package.json. Turbopack is the default for both dev and build in v16; the flag is now redundant. The build still passes (and is noticeably faster than the v15 webpack default).
  4. Audited image config defaults against the v16 changes. Nothing needed to change; the project already uses remotePatterns, has no quality props, and accepts the new 4-hour minimumCacheTTL.
  5. Confirmed the proxy.ts rename and the Edge-to-Node runtime change does not affect me because there is no middleware. Filed the knowledge for next time.
  6. Verified the v16 parallel-routes change does not bite this site. Parallel route slots now require an explicit default.js file or the build fails. The site has no parallel routes, so this is a non-issue, but the npx @next/codemod@canary upgrade latest codemod handles it automatically if you have any.
  7. Replaced the runtime MDX dynamic import (an await import() call whose path was a template string built from the slug) with a build-time-generated static registry. Hot reload silently no-op'd on content edits because the template-string import() was opaque to Turbopack's dependency tracker; static imports give the bundler something it can trace.

The pattern under all of these is the same: read the upgrade guide front to back. Do not trust tutorials, do not trust secondary sources, do not trust your own assumption that "I am on the latest version." The release notes are the contract.

What I would build next if this site had to grow

If I had a hypothetical Year Two of this site, here is the order I would reach for the unused primitives:

If I added a contact form with a real submit, that gets a server action with updateTag() after the database write so the user sees their submission immediately instead of waiting on stale data.

If I added a /now page that pulled from a slow source (latest GitHub activity, last book finished), that gets 'use cache' with a cacheTag and a cacheLife({ expire: 300 }) to make the page mostly static while the slow data refreshes on its own SWR window. Enabling cacheComponents: true unlocks this.

If I added a logged-in surface, that gets a proxy.ts with a Node-runtime auth check, plus 'use cache: private' on the per-user data.

If I added a real admin route, that gets a loading.tsx (because admin routes have legitimate latency from real database queries) and probably parallel routes for the dashboard panels.

None of these are speculative refactors I should ship now. They are upgrades that earn their place the moment the requirement that justifies them lands.

The signal I am trying to send

The frame I want a Vercel reviewer to leave this site with is: this person has read the docs, made deliberate decisions, and can explain the omissions out loud. Not "this person used every primitive in the framework." That second frame is the failure mode for portfolio sites. Every primitive earns its place; the ones that do not earn it stay on the shelf.

If you are prepping for the same interview I am prepping for, the move I would recommend is the same one I used for this post. Audit your own site against the v16 upgrade guide line by line. For every primitive you do not use, write down one sentence on why. If you cannot defend an omission in one sentence, that is the gap. Fix it, or write the sentence and move on.

Glossary

If you are newer to the Next.js ecosystem, a few terms used above with definitions that lean on how Vercel uses them in their own docs, with my own framing where I think it helps.

App Router. The current Next.js routing model, built around the app/ directory. Each folder is a URL segment and special filenames (page.tsx, layout.tsx, loading.tsx, error.tsx, route.ts) declare what each segment does. The predecessor is the Pages Router, which used pages/ and had a different mental model around data fetching. App Router is React Server Components native.

Server component. A React component that runs only on the server. Its code is never sent to the browser. It can read from the database or the filesystem directly, but it cannot use hooks like useState or event handlers like onClick. Server components are the default in the App Router.

Client component. A React component marked with 'use client' at the top of the file. Its code ships to the browser so it can use hooks, event handlers, and browser APIs. The senior pattern is to push client components as far down the tree as possible so the rest of the page stays on the server.

SSR, SSG, ISR. Three rendering modes the App Router collapses into a unified model. SSR (server-side rendering) renders the page fresh on each request. SSG (static site generation) renders the page at build time and serves the same HTML to everyone until the next build. ISR (incremental static regeneration) is SSG with an expiry, after which the page is regenerated on demand. In v16 the framing has shifted to "is this page dynamic or cached," and you opt into caching with the 'use cache' directive once cacheComponents is enabled.

Hydration. The process by which React takes a server-rendered HTML page and attaches event listeners to make it interactive in the browser. The HTML renders first (fast, no JavaScript needed), then the JavaScript bundle loads and "hydrates" the page. Server components produce HTML that requires no hydration; client components do.

generateStaticParams. A function exported from a dynamic segment route (like /blog/[slug]) that returns the list of values the route should prerender at build time. Without it, Next either skips static generation or generates pages lazily on first request, depending on configuration.

dynamicParams. A route segment config option. When set to false, any path not returned by generateStaticParams returns a 404 instead of being rendered on demand. Locks the URL space.

Streaming. A rendering technique where the server flushes parts of a page to the browser as they become ready instead of waiting for the entire page to render. Powered by React's Suspense boundaries. Pairs naturally with PPR.

Suspense boundary. A React primitive that lets you wrap an async component and declare what to show while it loads. The framework streams the page shell first and slots the async content in when it resolves.

loading.tsx. A route-level convention in the App Router. If you put a loading.tsx in a route folder, Next wraps the entire page in a Suspense boundary using that file as the fallback. Sugar over manually writing the Suspense.

Server action. An async function marked with 'use server' that runs on the server and can be called directly from a form or client component as if it were a local function. The framework handles the RPC plumbing. Use server actions for mutations triggered by your own UI.

Route handler. A route.ts file under app/ that exports GET, POST, or other HTTP method handlers. Use route handlers for webhooks, external API consumers, file uploads, or anything that needs a stable HTTP endpoint.

Middleware (now proxy.ts in v16). A file at the project root that runs before every matching request and can redirect, rewrite, or modify the response. In Next 16 the file was renamed to proxy.ts and the runtime was forced to Node.js (Edge runtime is no longer supported for this file).

Edge runtime. A lightweight JavaScript runtime (based on V8 isolates, not Node.js) that runs at Vercel's edge locations closer to the user. It has a smaller API surface than Node and excludes Node-specific modules like fs. Most route handlers in this site use the Node runtime explicitly because they need full Node APIs.

Turbopack. The Rust-based bundler that replaced Webpack as the default for Next 16 dev and build. Significantly faster than Webpack for both cold start and incremental compile.

PPR (Partial Prerendering) / Cache Components. A v16 feature, opted into by setting cacheComponents: true in next.config.ts. Lets a single route mix a static shell (served instantly from the CDN) with dynamic holes (streamed in as they resolve). Useful for pages that are mostly the same for everyone but have a few per-user or per-request pieces. This site does not use it because no route has that pattern yet.

'use cache', cacheLife, cacheTag. The v16 caching primitives. 'use cache' at the top of a function or component tells the compiler to memoize it. cacheLife sets the SWR window. cacheTag lets you invalidate selectively via revalidateTag or updateTag. All three were unstable_ in v15 and lost the prefix in v16.

revalidateTag vs updateTag. Both invalidate cached data by tag. revalidateTag marks data stale and refreshes in the background while users see the old value (SWR semantics). updateTag expires and refreshes synchronously so the next render sees the new value (read-your-writes semantics, server-action-only).

RSC payload. The serialized representation of a React Server Component tree that the framework sends to the browser. It is not HTML; it is a custom format React uses to reconcile server-rendered content with client-side state.

LCP (Largest Contentful Paint). The Core Web Vital that measures how long it takes for the largest visible element on a page to render. Common LCP elements are hero images, headline text, or above-the-fold backgrounds. Vercel Speed Insights and Chrome Lighthouse both report it.

CLS (Cumulative Layout Shift) and INP (Interaction to Next Paint). The other two Core Web Vitals. CLS measures visual stability (how much content jumps around as the page loads). INP measures responsiveness to user input. Both have direct ranking implications on Google.

generateMetadata. An async function exported from a page or layout that returns a Metadata object. Lets you compute per-page metadata (title, description, OG image, canonical URL) based on the route params.

ImageResponse / next/og. A Next utility that renders JSX directly to a PNG using Satori. Used for generating Open Graph share images dynamically per route without needing a headless browser.

JSON-LD. A structured data format Google reads to understand page semantics. Lets you mark a page as an Article, a Person, a BreadcrumbList, etc., so search engines render rich results. Embedded as <script type="application/ld+json"> in the page head.