# Migrate to Next.js Drizzle v2

> A guide to updating this kit from v1 to v2 using git and AI Agents

*Canonical: https://makerkit.dev/docs/nextjs-drizzle/installation/v2-migration*

---

The source for this page is available at `docs/installation/v2-migration.mdoc`. You can reference this file to AI agents for automatic migrations.

v2 is a major upgrade focused on one thing: **making navigations instant**.

- **Cache Components** — Partial Prerendering by default; you choose what to cache
- **Root params** — `[locale]` is readable anywhere via `next/root-params`, no prop drilling
- **Partial Prefetching** — one reusable App Shell per route instead of one prefetch per link
- **Suspense-first pages** — real content paints immediately, data streams into place
- **Considered loading states** — route-shaped skeletons replace full-page spinners
- **Statically generated content** — blog, changelog and help prerender at build time
- **CDN-safe marketing pages** — the marketing site can finally be cached at the edge

The result: navigating between pages shows real, cached content immediately
instead of a spinner while the server works.

Measured on the kit itself, the route table went from **1 static / 0 partially
prerendered / 37 dynamic** to **15 static / 54 partially prerendered / 3
dynamic** — the three that remain are route handlers, which only ever run at
request time.

This guide covers every breaking change and what you need to update if you
customized the codebase.

## Requirements

v2 requires **Next.js 16.3 or later**. Root params (`next/root-params`) do not
exist before 16.3, and Step 2 depends on them; `cacheComponents` in Step 6 needs
the same version.

**The bump is not one of the steps.** It landed on the v1 line before v2 was
branched, because nothing about it is specific to v2 — so if you are up to date
you already have it. [Before starting the
migration](#before-starting-the-migration) shows you how to check.

16.3 has no stable release yet, so the catalog currently tracks `16.3.0-preview`.
It will be re-pinned to `16.3.0` once that ships; the change is a single line in
`pnpm-workspace.yaml`.

## How long will it take?

It depends almost entirely on how much of the app directory you have
customized, so treat any number here as a rough guide rather than an estimate.

If you haven't customized much, most steps are `git pull` + `pnpm install` with
few conflicts, and the work is reading the validation checklists.

If you've heavily customized the app directory (custom pages that fetch data,
custom layouts, a rewritten marketing site), budget considerably more. This
upgrade touches how *every* page is structured, so the work scales with how many
custom pages you have — and Steps 6 and 7 are where nearly all of it lands.

The migration is split into 9 steps for a reason — each step is self-contained
and your app should build after each one. You don't have to do it all at once.
Merge one step, verify it works, ship it to production if you want, then come
back to the next one tomorrow or next week. There is no rush.

## Should I just start from scratch?

**No.** Starting from scratch means losing all your customizations, git history,
and deployed infrastructure. The incremental upgrade preserves everything and
lets you ship each step independently.

The only scenario where starting fresh might make sense is if you've barely
customized the kit or have only started using it in the past week or so.

## Not Ready to Upgrade?

That's okay!

The `v1` branch is available as a long-term support (LTS) release. It will
receive important updates.

If you're not ready to upgrade now, you can switch to the `v1` branch:

```bash
git checkout v1
```

From now on, pull updates exclusively from `v1`:

```bash
git pull upstream v1
```

v2 is a performance upgrade, not a functionality upgrade. Your app works fine on
v1. Upgrade when you have the time to do it properly — rushing this one has no
upside.

## What actually changes

The core idea is small, and everything else follows from it.

In v1, a page fetched all its data at the top and returned finished HTML. Nothing
rendered until the slowest query came back, so every navigation showed a spinner.

In v2, a page returns immediately with everything it already knows — headers,
navigation, card chrome, cached content — and the parts that need fresh data
stream into `<Suspense>` boundaries as they resolve.

```diff
- async function Page({ params }) {
-   const members = await loadMembers();   // blocks the entire page
-   return <PageBody><PageHeader /><MembersTable members={members} /></PageBody>;
- }

+ function Page({ params }) {
+   return (
+     <PageBody>
+       <PageHeader />
+       <Suspense fallback={<TableSkeleton />}>
+         <MembersTable params={params} />     {/* only this waits */}
+       </Suspense>
+     </PageBody>
+   );
+ }
```

That's the whole migration, applied route by route. Everything below is either
enabling this pattern or cleaning up things that block it.

## How the Upgrade Works

v2 is delivered as **9 incremental PRs**, each merged in order. Every PR is a
self-contained step — your app should build and run after each one.

If you haven't customized a particular area, `git pull` handles it automatically
— only read the sections relevant to your changes.

### Merge Order

Merge these in exact order. Each step depends on the previous ones. Each step is
tagged so you can merge incrementally:

| # | Tag | What It Does |
|---|-----|-------------|
| 1 | `v2-step/root-layout` | Makes `app/[locale]/layout.tsx` the real root layout |
| 2 | `v2-step/root-params-i18n` | Resolves the locale via `next/root-params` |
| 3 | `v2-step/loading-states` | Replaces full-page spinners with considered loading states |
| 4 | `v2-step/marketing-auth` | Makes marketing pages CDN-cacheable |
| 5 | `v2-step/suspense-boundaries` | Adds `<Suspense>` across the app |
| 6 | `v2-step/cache-components` | Enables `cacheComponents` |
| 7 | `v2-step/static-content` | Prerenders and caches blog, changelog and help |
| 8 | `v2-step/partial-prefetching` | Enables `partialPrefetching` |
| 9 | `v2-step/design` | Visual refresh — the only step that is purely cosmetic |

There is no dependency step. The Next 16.3 bump these tags need is already on the
branch you are migrating from — see [Before starting the
migration](#before-starting-the-migration).

Steps 1-2 are structural and low-conflict. Steps 3-4 are contained. **Steps 5 and
6 are where your time will go** — 5 restructures every page that fetches data, and
6 is the one that fails one route at a time until each is fixed. Steps 7-8 are
mostly configuration. Step 9 is optional and independent of everything else.

### Every step is a working state

This is the rule the step order is built around: **after each step your app
builds, runs, and is deployable.** No step leaves you in a half-migrated state
that only compiles once the next step lands.

Three consequences worth knowing before you start:

- **`use cache` is not available until Step 6.** The directive requires
  `cacheComponents`, which Step 6 enables. Steps 1-5 deliberately use only APIs
  that work without it — `<Suspense>`, `generateStaticParams`, and ordinary
  component restructuring. If an agent suggests adding `use cache` before Step 6,
  it is wrong and your build will fail.
- **The order exists so you never touch a file twice.** Step 5 puts your data
  access behind `<Suspense>`, which is what lets Step 6 turn the flag on cleanly.
  Step 7 then caches that same data in one pass, with `use cache` available. If
  you reorder these, you will either do the work twice or leave `instant = false`
  scattered through your app.
- **Steps 1-5 are valuable on their own.** Suspense boundaries, considered
  loading states, and a cacheable marketing site all improve your app under the
  v1 rendering model. If you stop after Step 5, you keep every one of those
  gains. Steps 6-8 turn them into guaranteed-instant navigation.

After merging the last tag, merge the latest `main` to pick up any fixes released
after the migration tags:

```bash
git pull upstream main
```

### Before starting the migration

Make sure your `main` branch is up to date, and that `typecheck`, `lint`, and
`format` run without errors:

```bash
git pull upstream main
```

```bash
pnpm typecheck
pnpm lint
pnpm format
```

If any of these return errors, fix them before starting the migration.

### You already have the dependency bump

The Next 16.3 bump the migration needs is **not** one of the tags below. It
landed on the v1 line as an ordinary catalog refresh, because nothing about it is
specific to v2 and a fork that is not migrating yet still wants it. Bringing your
branch up to date, which you just did, is all it takes.

Check the resolved version:

```bash
node -e "console.log(require('next/package.json').version)"
```

Anything below 16.3 means you are still behind — pull again before you start. If
you pin Next yourself rather than using the catalog, update your own pin now.

Conflicts from that update land in `pnpm-lock.yaml`. Do not merge that file by
hand — take either side and regenerate it:

```bash
git checkout --ours pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
```

{% callout type="warning" title="Check the resolved version first" %}
If anything in Steps 1-2 appears not to work, check the resolved Next version
before touching your app structure. On 16.2.x, `next typegen` silently does not
emit `root-params.d.ts` at all — there is no error, the file simply never
appears.
{% /callout %}

### Work on a separate branch

We recommend performing the migration on a dedicated branch. This keeps your
`main` branch stable and deployable while you upgrade, and gives you an easy
escape hatch if anything goes wrong.

**Option A: One branch per step** — Create a branch for each step (e.g.
`v2/root-layout`, `v2/static-content`), merge it into `main` after verifying,
then start the next step from `main`. This lets you deploy each step
independently.

**Option B: Single migration branch** — Create one `v2-migration` branch, merge
all 9 steps into it, then merge the whole thing into `main` at the end.

Option A is safer for production apps. It is strongly recommended for this
particular upgrade, because steps 4-6 change runtime rendering *and* where
authentication is enforced, and you want to catch that in isolation.

### Step-by-Step Process

For each step below, follow this process:

**1. Create a branch and merge the tag:**

```bash
git checkout -b v2/<step-name>
git pull upstream <TAG>
pnpm install
```

Always run `pnpm install` after each `git pull` — several steps change
dependencies.

**2. Resolve conflicts.** If easy enough, resolve manually first.

**3. Run an AI-assisted review** with a coding agent (Claude Code, Cursor, etc.).
Point it at this page — `docs/installation/v2-migration.mdoc` — and at the step
you just merged, and ask it to check your customizations against what changed.

**4. Validate** — check the "Validate Before Continuing" checklist for that step.

**5. Commit and optionally merge.**

---

## Table of Contents

1. [Root Layout](#1-root-layout)
2. [Root Params + i18n](#2-root-params--i18n)
3. [Loading States](#3-loading-states)
4. [Marketing Auth](#4-marketing-auth)
5. [Suspense Boundaries](#5-suspense-boundaries)
6. [Cache Components](#6-cache-components)
7. [Static Content](#7-static-content)
8. [Partial Prefetching](#8-partial-prefetching)
9. [Design Updates](#9-design-updates)
10. [After Upgrading](#after-upgrading)

---

## 1. Root Layout

```bash
git checkout -b v2/root-layout
git pull upstream v2-step/root-layout
pnpm install
```

### What changed

`apps/web/app/layout.tsx` is deleted, and `apps/web/app/[locale]/layout.tsx`
becomes the real root layout. The `globals.css` import moves with it.

`apps/web/app/not-found.tsx` becomes `apps/web/app/global-not-found.tsx`, and
`experimental.globalNotFound` is enabled in `next.config.ts`.

### Why this is needed

Root params are the dynamic segments that appear **before** the root layout. v1
shipped a pass-through `app/layout.tsx` that only returned `children`; its sole
job was to give `app/not-found.tsx` a layout to render into. That pass-through
counted as the root layout, which put `[locale]` *below* it — so `[locale]` was
not a root param, and Step 2 could not work.

### Why the 404 moves rather than shrinks

In this kit, the **global** 404 — not `[locale]/not-found.tsx` — is what an
unknown URL reaches. `[locale]/not-found.tsx` only renders when a route that
*did* match calls `notFound()`, such as a blog post that doesn't exist.

That means the global 404 is user-facing and has to stay themed and translated.
Sitting above the root layout it inherits no providers and no stylesheet, and a
plain `not-found.tsx` has its `<html>` attributes dropped by Next's default
document shell — losing `lang`, the theme class and the font. `global-not-found.tsx`
renders the whole document itself, which is exactly what this file needs.

### Directory structure

What may remain at `app/` root: route handlers and metadata files only.

```txt
apps/web/app/
├── [locale]/
│   └── layout.tsx          ← the root layout
├── api/
├── global-error.tsx
├── global-not-found.tsx    ← renders its own <html>
├── robots.ts
└── sitemap.xml/
```

### If you customized the root layout

Any provider, script tag or class you added to `app/layout.tsx` needs to move to
`app/[locale]/layout.tsx`. Check `<head>` contents especially — analytics
snippets and font preloads are commonly added there.

### AI prompt

```text
I merged v2-step/root-layout. app/layout.tsx was deleted and
app/[locale]/layout.tsx is now the root layout, and app/not-found.tsx became
app/global-not-found.tsx.

Check that any customizations I had in app/layout.tsx (providers, scripts, head
tags, classes) survived the move into app/[locale]/layout.tsx, and that nothing
page-like is left directly under app/ other than route handlers and metadata
files.
```

### Validate Before Continuing

```bash
pnpm --filter web exec next typegen
cat apps/web/.next/types/root-params.d.ts
```

Expected:

```typescript
declare module 'next/root-params' {
  export function locale(): Promise<string>
}
```

- [ ] `root-params.d.ts` declares `locale()`
- [ ] `/` returns 200 with the correct `<html lang>`
- [ ] An unknown URL returns 404 and renders your themed 404 page
- [ ] Your custom root layout changes survived the move

`No root params detected` means something is still above `[locale]`.

{% callout type="info" title="Check the right output path" %}
`next typegen` writes to `.next/types/`, while `next dev` writes to
`.next/dev/types/`. Checking the wrong one reads as a stale failure.
{% /callout %}

---

## 2. Root Params + i18n

```bash
git checkout -b v2/root-params-i18n
git pull upstream v2-step/root-params-i18n
pnpm install
```

### What changed

`apps/web/i18n/request.ts` resolves the locale from `next/root-params` instead
of next-intl's `requestLocale`:

```diff
+ import { locale as localeRootParam } from 'next/root-params';

- export default getRequestConfig(async ({ requestLocale }) => {
-   let locale = await requestLocale;
+ export default getRequestConfig(async (params) => {
+   let locale = params.locale ?? (await resolveLocale(params));
```

### Why

`requestLocale` is a lazy getter that calls `headers()` **the moment it is
read**. That single read is what made every route in v1 request-bound. Reading
the root param instead costs nothing and keeps pages prerenderable.

### `requestLocale` survives as a fallback

Root params are Server Components only — they throw in Server Actions, Route
Handlers and `unstable_cache`. Two call sites in this kit are exactly that: the
billing server actions and the Stripe plugin's checkout locale.

So `resolveLocale` tries the root param and falls back to `requestLocale`:

```typescript
async function resolveLocale(params: {
  requestLocale: Promise<string | undefined>;
}) {
  try {
    return await localeRootParam();
  } catch {
    return await params.requestLocale;
  }
}
```

Note that `requestLocale` is read off the params object rather than destructured.
Destructuring invokes the getter, which would fire the `headers()` call in every
Server Component and undo the whole point of this step.

{% callout type="warning" title="Breaking: do not import next-intl/server into anything the proxy reaches" %}
`proxy.ts` imports the Better Auth instance, which reaches every Better Auth
plugin. If one of those plugins imports `next-intl/server`, it pulls next-intl's
server config — and with it `apps/web/i18n/request.ts` and `next/root-params` —
into the **middleware bundle**, where root params cannot be bundled at all. The
build fails with:

```txt
'next/root-params' can only be used inside the App Directory.
```

A `try`/`catch` does not help: this is a bundler-level rejection, not a runtime
error. Neither does a dynamic `import()` — Turbopack keeps those in the module
graph too.

This step fixes it in the kit by having the Stripe plugin read next-intl's
`NEXT_LOCALE` cookie directly instead of calling `getLocale()`. If you added your
own Better Auth plugin that reads translations, apply the same fix.
{% /callout %}

### Where root params do not work

Server Components only. They throw in Client Components, Server Actions, Route
Handlers and `unstable_cache`.

**Audit this in your own code.** In this kit every remaining `next-intl/server`
call site is a Server Component, so the restriction costs nothing. If you render
a localized email inside a Server Action, it will hit the fallback path — which
works, but pass an explicit locale via `getTranslations({ locale })` if you want
to be certain.

### `setRequestLocale` is not a substitute

React's `cache()` scope does not cross a `use cache` boundary, so priming
`setRequestLocale` does not make next-intl work inside cached components.
Resolving in `getRequestConfig` is the load-bearing change. Public write-ups list
both fixes without distinguishing them.

### Validate Before Continuing

Testing only your default locale proves nothing — if the root param silently
returned `undefined`, every page would still render correctly in your default
language. You need a second locale and a marker string:

```bash
# enable a second locale in packages/i18n/src/locales.tsx (note: .tsx, not .ts)
mkdir -p apps/web/i18n/messages/es
# copy your en namespaces into es/, then stamp a marker into one of them
```

| URL | `<html lang>` | marker |
|---|---|---|
| `/` | `en` | 0 |
| `/es` | `es` | present |

- [ ] The marker only appears under the second locale
- [ ] `grep -rn "requestLocale" apps packages` returns only `i18n/request.ts`
- [ ] Your build succeeds

Revert the locale and message files afterwards.

---

## 3. Loading States

```bash
git checkout -b v2/loading-states
git pull upstream v2-step/loading-states
pnpm install
```

### What changed

All five `loading.tsx` files rendered the same full-page spinner. Each now gets
the treatment its predictability justifies, with shared blocks in
`apps/web/components/skeletons/page-skeletons.tsx`.

| Route | Treatment |
|---|---|
| `(internal)/` | neutral indicator — the shell is not knowable here |
| `(internal)/dashboard/` | page header only |
| `(internal)/settings/` | page header + reserved space |
| `admin/` | page header + reserved space |
| `auth/` | minimal |

### Why not skeleton everything

Two failure modes, both worse than a spinner:

- **Unpredictable shape.** `(internal)/loading.tsx` sits *above* the internal
  layouts, so it stands in for the whole shell — but `dashboard/` renders
  `AppSidebar` while `settings/` renders `SettingsSidebar`. A confident wrong
  guess across the whole viewport reads worse than a neutral indicator.
- **Invented content.** The dashboard body is an `ssr: false` dynamic import with
  its own loading overlay. Drawing tiles there would flash, be replaced by that
  overlay, and only then become real content — three states instead of one.

The rule: **treatment scales with surface area × predictability.** Where the
shape is unknown but the surface is large, reserve the space and draw nothing.

### Skeletons sit behind a CSS-only delay

Every skeleton is wrapped in `Delayed`, which keeps it invisible for 200ms then
fades it in. Fast navigations paint nothing at all. No timers, no client
component:

```tsx
'animate-in fade-in fill-mode-both delay-200 duration-300'
```

`fill-mode-both` is the load-bearing class — it applies the animation's starting
state during the delay. Without it the children are visible while waiting and the
delay does nothing.

{% callout type="warning" title="Verify your animation utilities exist" %}
This uses `tw-animate-css`. In 1.4.0, `animate-in` is a **theme variable**, not a
`@utility`, and the delay comes from `delay-*`. If these classes don't exist in
your version they silently no-op — the delay disappears and you get flicker back
with no error. Confirm the built CSS actually contains `animation-delay` and
`animation-fill-mode:both`.
{% /callout %}

### Placement matters

A `loading.tsx` **inside** a layout replaces only the page area, so the chrome
stays on screen and the skeleton must not redraw it. One **above** a layout has
to stand in for the whole shell — which is exactly the case where the shape is
usually least predictable.

### Validate Before Continuing

```bash
pnpm typecheck
pnpm --filter web build
```

- [ ] Your custom `loading.tsx` files still render
- [ ] The built CSS contains `animation-delay` and `animation-fill-mode:both`
- [ ] Navigating quickly between pages paints no skeleton at all

---

## 4. Marketing Auth

```bash
git checkout -b v2/marketing-auth
git pull upstream v2-step/marketing-auth
pnpm install
```

### What changed

`(public)/layout.tsx` no longer exports `dynamic = 'force-dynamic'`, and
`SiteHeader` no longer awaits `getSession()`. `SiteHeaderAccountSection` became a
client component that reads the session with `authClient.useSession()`.

**The `user` prop is gone from `SiteHeaderAccountSection`.**

### Read this section even if you didn't customize anything

The security property changes *kind*. Previously a flag kept user data out of
shared caches. Now the server HTML contains no user data at all, so it is safe on
any CDN regardless of configuration.

This matters because Step 6 makes your marketing pages publicly cacheable
(`s-maxage`). If you reintroduce a server-side session read on a marketing page
after this step, that page will be cached **with one user's data in it** and
served to everyone.

### Verify it yourself

A grep that finds nothing proves nothing. The test needs two controls:

1. **Control the cookie works** — an authenticated route must redirect without it
   and return 200 with it. Skipping this is how you get a false pass from a
   malformed cookie.
2. **Control the scan works** — it must find your email on an authenticated page.
3. Only then assert marketing routes are clean.

Scan every string in the session object, not just the email — id, tokens and
metadata all count.

Stronger still: diff the anonymous and authenticated HTML for the same URL. On
the kit they are byte-identical.

### Validate Before Continuing

```bash
pnpm typecheck
pnpm --filter web build
```

- [ ] Signed out, marketing pages render with sign-in/sign-up buttons
- [ ] Signed in, marketing pages show your account dropdown
- [ ] Marketing HTML contains zero session strings when requested with a session
- [ ] Any custom marketing component that took a `user` prop has been updated

---

## 5. Suspense Boundaries

```bash
git checkout -b v2/suspense-boundaries
git pull upstream v2-step/suspense-boundaries
pnpm install
```

This is the step where your time will go if you have custom pages.

### What changed

Both internal layouts awaited everything before returning anything, so every page
under them blocked on data it does not read. What suspends differs per layout,
because what needs the data differs:

- **`AppSidebar`** suspends only the **workspace dropdown**. The organization
  list feeds that one widget; the frame, the navigation and `children` do not
  need it.
- **`SettingsSidebar`** suspends the **navigation** instead. There the nav is
  what needs data — account context, active organization, role permissions — up
  to three round trips, while the back button is static.

`blog` and `changelog` render their page header immediately and suspend the list.

### The pattern

Pass promises down; await them inside the boundary:

```diff
- export async function Layout({ children }) {
-   const [session, organizations] = await Promise.all([
-     requireSession(),
-     getOrganizations(),     // children wait for this too
-   ]);
-   return <Shell organizations={organizations}>{children}</Shell>;
- }

+ export async function Layout({ children }) {
+   const session = await requireSession();
+   const organizationsPromise = getOrganizations();   // not awaited
+
+   return (
+     <Shell>
+       <Suspense fallback={<DropdownFallback />}>
+         <DropdownLoader organizationsPromise={organizationsPromise} />
+       </Suspense>
+       {children}
+     </Shell>
+   );
+ }
```

### Suspend the smallest region that actually needs the data

Wrapping a whole sidebar in `<Suspense>` builds and streams correctly and looks
*worse* than before, for two reasons that generalize:

1. **A suspended region reserves no space.** Wrapping a structural element means
   the page has no sidebar-shaped hole waiting for it.
2. **Most of it never needed the data.** The frame and the navigation items come
   from config; only one widget reads the workspace.

**Before wrapping anything, split it and check what actually touches the data.**

### Structural fallbacks must not use `Delayed`

The 200ms delay from Step 3 avoids flicker in content areas. In a fixed slot that
is always occupied it just leaves a blank gap and then pops in. Use a plain
skeleton sized to the real element.

### When you can't predict the shape, reserve the space

"Skeleton or nothing" is a false choice. Reserving space is independent of
drawing content. For blog and changelog the kit uses a `min-h` container with a
delayed spinner — it holds the layout, draws nothing, and gives feedback only if
the wait is real. Grey card grids were tried first and rejected: image and
headline lengths vary too much, and on a public page they read as cheap.

| Region | Fallback |
|---|---|
| Known shape (table, cards, page header) | Matching skeleton |
| Unknown shape, large surface | Reserved space, no content |
| Fixed slot, resolves fast | Small skeleton, no delay |

### How to tell it actually worked

A page can render correctly and still be entirely blocking. Assert on the
**stream**, not the finished page:

```bash
curl -s -H "Cookie: $COOKIE" http://localhost:3000/settings \
  | grep -c 'data-slot="skeleton"'
```

A hit means the fallback was flushed while data was still resolving. Zero means
everything resolved before the first byte — the boundary is misplaced, or nothing
under it suspends.

Even better, count React's own streaming markers. `<template id="B:0">` and
`$RC(` only appear when a boundary genuinely deferred:

```bash
curl -s http://localhost:3000/blog | grep -c '<template id="B:'
```

This alone is not sufficient — it proves streaming happened, not that the result
looks right. Also confirm structural elements are in the first flush, and check
visually with the network throttled. Layout shift is the failure mode curl cannot
see.

### Validate Before Continuing

```bash
pnpm typecheck
pnpm --filter web build
```

- [ ] Authenticated pages show their shell immediately
- [ ] Streaming markers appear on pages that fetch data
- [ ] Nav links and page chrome are in the first flush, not behind a boundary
- [ ] No layout shift when data lands (check with the network throttled)

---

## 6. Cache Components

```bash
git checkout -b v2/cache-components
git pull upstream v2-step/cache-components
pnpm install
```

This is the largest step. Steps 1-5 exist to make it cheap.

### What changed

`cacheComponents: true` in `next.config.ts` — note it is **top-level**, not under
`experimental`. This enables Partial Prerendering as the default: every route
ships a static shell, and uncached data streams into it.

The kit ships **zero `instant = false` exports**. The step order is designed so
the flag turns on cleanly.

### Expect one root cause behind many routes

Failures cluster. Fix the topmost cause and rebuild — most of the list
disappears. The build fails one route at a time, so there is no way to get a full
list up front.

Use `next build --debug-prerender` when an error names no component. It is the
difference between "something uses `usePathname`" and "`SiteNavigationItem` at
line 33".

### What had to change in the kit

| Cause | Fix |
|---|---|
| `dynamic` segment configs | Remove — every page is dynamic by default now |
| Root param without `generateStaticParams` | Add to `[locale]/layout.tsx` |
| Theme cookie read in the root layout | next-themes owns the class |
| CSP nonce `headers()` read | Gate on `ENABLE_STRICT_CSP` |
| `new Date()` in the footer | Cache it |
| Auth gating in `(internal)/layout.tsx` | Move to `proxy.ts` |
| `usePathname` in the site navigation | Split the component |

### Unstable values are not fixed by `<Suspense>`

This is a different error class from uncached data. Uncached data stops the
prerender gracefully; an unstable value **errors**, because the same render would
produce a different answer next time.

```txt
Next.js encountered the unstable value `new Date()` while prerendering.
```

`instant = false` does not clear these either. Either cache the value, move it to
the client, or call `await connection()` before it to move the render to request
time.

{% callout type="warning" title="`void connection()` does nothing" %}
The stall *is* the unresolved promise, so it must be awaited. This is genuinely
unlike `void cookies()`, which tracks synchronously — which makes the wrong
version look right.
{% /callout %}

### The theme cookie has to go

A cookie that drives a class on `<html>` in the root layout makes the entire
subtree request-bound, and above the root layout there is no child left to wrap
in `<Suspense>`.

next-themes owns the class now: it injects an inline script that applies the
stored preference before first paint, so there is no flash. The mode toggles
still write the `theme` cookie alongside localStorage — it is simply no longer
read during render.

**Breaking:** `getRootTheme()` is replaced by `getDefaultThemeMode()`, and
`getRootClassName()` no longer takes a theme argument.

{% callout type="warning" title="`<html>` now needs suppressHydrationWarning" %}
Because next-themes sets the class on `<html>` before hydration, the server
markup and the first client render disagree **by design**. Without
`suppressHydrationWarning` on that element you get a hydration warning on every
single route:

```tsx
<html lang={locale} className={getRootClassName()} suppressHydrationWarning>
```

This is next-themes' own documented requirement. It applies one level deep only,
so genuine mismatches elsewhere are still reported.

If you render `<html>` anywhere else — a `global-not-found.tsx`, for instance —
it needs the same attribute. And note this does **not** show up as a Cache
Components error: the route validates fine and the build is green. It only
appears as a React hydration warning in the browser console.
{% /callout %}

### A CSP nonce can never live in a static shell

It must be unique per response, and a cached shell would reuse it. `use cache:
private` does **not** help — it is explicitly excluded from static shell
generation. Strict CSP is opt-in and off by default here, so the read is gated on
the flag. If you need strict CSP always, accept `instant = false` on the root
layout.

### Auth gating moves to the proxy

{% callout type="warning" title="Breaking, and security-relevant" %}
A layout that awaits the session in order to redirect cannot prerender, and it
gated every route beneath it. Authentication for `(internal)` routes now happens
in `proxy.ts`:

```typescript
function isProtectedPathname(pathname: string) {
  const protectedPrefixes = [appConfig.appHomePath, '/settings'];
  return protectedPrefixes.some(
    (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
  );
}
```

**If you added your own authenticated route group, add its prefix here.** A route
under `(internal)` is no longer protected by the layout alone. Verify every
authenticated route still redirects when signed out before you deploy this step.

Before adding any proxy prefix, list the route handlers beneath it. A pattern
like `/join/*?` also matches `/join/accept`, and a route handler that must run
signed out — verifying a signed link and minting a session — would be redirected
away before it ever ran. In this kit every route handler lives under `/api/*` or
`/sitemap.xml`, both excluded by the matcher, so the prefixes swallow none of
them.
{% /callout %}

Gating in the proxy keeps the `307` that a streamed in-page redirect would have
turned into a `200`.

### Client queries must be seeded — but not in this kit

Any component gated on a client query renders its loading state during SSR and
real content on the client. It surfaces as empty cards, permanent spinners and
hydration mismatches — **never as a validator error**, which is what makes it
hard to diagnose. It appears only after adding Suspense boundaries, so it looks
like the boundaries caused it. They didn't; they exposed it.

This kit passes server data as props through `toClientUser` / `toClientOrganization`
rather than gating on client queries, so no `HydrationBoundary` is needed. **If
you added client-query-gated components, check them** — grep for
`HydrationBoundary` and `dehydrate` first.

### UI state is now preserved across navigation

Cache Components enables React's `<Activity>`: the route you leave stays mounted
and hidden rather than unmounting.

**Audit every dialog whose submit ends in `redirect()`.** Under v1 the navigation
unmounted it, so nobody needed to close it. It will now reappear — backdrop and
all — when the user returns to that route.

### Your e2e tests will break, and the failure is misleading

Every `data-test` selector matches twice after a navigation, and the stale copy is
zero-size so screenshots still look correct. `page.locator` / `getByTestId` are
strict and report it; `page.click` / `page.fill` are **not** — they take the first
match, often the invisible one, and hang until the test times out with no error.

Scope your selectors with `:visible`. Count DOM matches first
(`await page.locator(sel).count()`) — redirect counts changing at steps 5-6 look
like the obvious culprit and are a red herring.

### The build is not enough — walk the app in dev

Insights don't show up in the HTTP response. An offending route still returns 200
with rendered HTML. The insight appears only in the dev overlay or the dev-server
log. Load every route in `next dev`.

### Loading a route is not enough either — you have to navigate to it

There are two classes of insight, and they surface differently:

| Insight | When it appears |
|---|---|
| `blocking-prerender-dynamic` — uncached data outside `<Suspense>` | On a direct page load |
| `instant-shell-url-data` — `params`/`searchParams` outside `<Suspense>` | **Only on a client-side navigation** |

A page that awaits `searchParams` at the top loads perfectly on a direct hit and
reports nothing. Click a `<Link>` to it and the overlay flags it, because the
shared App Shell cannot contain data belonging to one URL.

This is easy to miss if you audit by visiting URLs. Auditing this kit that way
reported every anonymous route clean, and `/auth/sign-in` was still blocking.

The reliable check is static — every page that awaits URL data at the top is a
candidate:

```bash
grep -rn "await searchParams\|await props.searchParams\|await params" \
  apps/web/app packages/*/src --include="page.tsx"
```

For each hit, confirm the await happens inside a `<Suspense>`-wrapped child
rather than at the top of the page component. The fix is the same as everywhere
else: keep the page synchronous, pass the promise down, await it in the child.

```diff
- async function Page({ searchParams }) {
-   const params = await searchParams;      // ties the shell to one URL
-   return <><Heading /><Form params={params} /></>;
- }

+ function Page({ searchParams }) {
+   return (
+     <>
+       <Heading />
+       <Suspense fallback={<Spinner />}>
+         <PageContent searchParams={searchParams} />
+       </Suspense>
+     </>
+   );
+ }
```

The kit's auth pages, admin lists and organization-scoped settings pages are all
structured this way now. If you added pages that read `searchParams`, they are
the most likely thing this step misses.

### Validate Before Continuing

```bash
pnpm --filter web build
grep -rn "export const instant = false" apps/web/app | wc -l
```

- [ ] The build succeeds
- [ ] Every route is clean in the dev overlay on a **direct load**
- [ ] Every route is clean after **navigating to it via a `<Link>`** — this is a
      separate check, and the only one that catches URL-data insights
- [ ] No hydration warnings in the console on any route
- [ ] Every authenticated route redirects when signed out
- [ ] Non-admins are redirected away from `/admin`
- [ ] Marketing pages contain zero session strings with a live session
- [ ] Authenticated routes are `private, no-store`
- [ ] Dark mode has no flash on hard refresh
- [ ] Your `instant = false` count is as low as you can get it

---

## 7. Static Content

```bash
git checkout -b v2/static-content
git pull upstream v2-step/static-content
pnpm install
```

### What changed

`generateStaticParams` plus `use cache` on blog, changelog and help, in one pass.
All bundled content is now built ahead of time.

### Remove `cache()` wrappers from anything you cache

React's `cache()` and `use cache` **do not compose**:

```txt
TypeError: Invalid value used as weak map key
    at generateMetadata
```

`cache()` keys a WeakMap on function identity, which the transform invalidates.
The error names `generateMetadata` rather than the loader, so it misdirects.

Grep for `cache(` before converting anything. The wrapper is also redundant —
`use cache` dedupes across requests rather than only within one.

Request-scoped loaders that read the session keep `cache()`: they can never be
`use cache` because they are per-request by definition.

### `generateStaticParams` shape

Catch-all routes need an array, not a string:

```typescript
// help/[...slug] — the stored "a/b" slug splits back into ['a', 'b']
return items.map((item) => ({ slug: item.slug.split('/') }));
```

### Return at least one param

Returning `[]` is now a build error. Paths you *don't* return are still served —
Next sends the App Shell and fills it in once the params are known — so returning
a subset is safe. Returning nothing is not.

{% callout type="warning" title="Check your CMS default limit" %}
The keystatic client defaults to `limit ?? 10`. Without an explicit
`limit: Infinity`, `generateStaticParams` silently prerenders only the first 10
items — check your own CMS client's default if you switched off keystatic — and the build table hides this behind a `[+N more paths]` line. Count the
emitted HTML rather than trusting the table:

```bash
find apps/web/.next/server/app -name '*.html' | grep -c blog
```
{% /callout %}

### Validate Before Continuing

```bash
pnpm --filter web build
```

- [ ] Every blog post, changelog entry and help page has prerendered HTML
- [ ] Content pages return `s-maxage` rather than `no-store`
- [ ] No `cache()` wrapper remains on anything marked `use cache`

---

## 8. Partial Prefetching

```bash
git checkout -b v2/partial-prefetching
git pull upstream v2-step/partial-prefetching
pnpm install
```

### What changed

One line: `partialPrefetching: true`. The work is the link audit.

### What changes for `<Link>`

| `<Link>` prop | Before | After |
|---|---|---|
| `<Link href="/x">` | Prefetched the cached page render | Loads the shared App Shell |
| `<Link href="/x" prefetch>` | Prefetched the page **and** its dynamic content | Loads the App Shell; per-link runtime data only if the route opts into runtime prefetching |
| `<Link href="/x" prefetch={false}>` | Disabled | Unchanged |

The App Shell is shared across every link to a route, so rendering many links to
the same destination doesn't multiply the work.

### Audit every `<Link prefetch={true}>`

All three in the kit were removed — their destinations are carried by the App
Shell, so the old full prefetch was redundant.

Two of them pointed at the auth pages with `email` and `redirect` in the query
string. That is URL data, which normally argues for keeping `prefetch={true}` —
but nothing *cached* sits behind those params; they only prefill a client form.
Runtime prefetching would buy nothing, and dropping the flag also stops prefetch
requests putting an email address in a query string.

### The kit uses `allow-runtime` on nothing, and probably neither should you

Runtime prefetching only pays off when the URL-dependent content is **cacheable**.
It re-renders the route with the URL resolved, advancing through static and
`use cache`-backed work, then stops at the first uncached read.

Every per-account read here goes through a request-bound session and can never
sit in a plain `use cache`, so the prerender would stop at the same boundary the
App Shell already stops at — a server render per visible link, for identical UI.

**The test to apply: can the data behind the URL be cached?** For a
session-scoped app the answer is usually no.

### Validate Before Continuing

```bash
pnpm --filter web build
grep -rn "prefetch={true}" apps/web packages
```

- [ ] The build succeeds
- [ ] Every remaining `prefetch={true}` has a reason you can state
- [ ] Navigation still feels instant

---

## 9. Design Updates

```bash
git checkout -b v2/design
git pull upstream v2-step/design
pnpm install
```

### This step is different

It is purely cosmetic and independent of steps 1-8. **It is also the
highest-conflict step**, because it touches exactly the files you are most likely
to have restyled. It is entirely optional — skip it and everything else still
works.

**It also cuts the release.** Steps 1-8 ship as `2.0.0-rc.N`; this step sets
`2.0.0`. If you skip it you stay on `2.0.0-rc.8` — the app is complete and
supported, you just bump the version yourself whenever you are ready.

### What changed

- **`@kit/ui/avatar` now resolves to a makerkit wrapper** rather than the shadcn
  primitive, so the rounded-square shape is applied once instead of by ad-hoc
  `rounded-md!` overrides at each call site. `src/shadcn/` stays untouched so the
  shadcn CLI can still replace it.
- **`LastAuthMethodHint` is now a badge** pinned to the button for the method you
  actually used, instead of a line of text above the form.
- **Auth layout**: the logo is pinned to the top instead of sitting in the centred
  column, and the card loses its padding and border.
- Auth headings move to `h2`, subheadings are dropped, sign-in names the product.
- FAQ, hero, nav spacing, plan-picker arrow, centred OTP input.

### Breaking API changes

```diff
- <LastAuthMethodHint lastMethod={lastAuthMethod} />
+ <LastAuthMethodHint type="password" />
+ <LastAuthMethodHint type="oauth" name={provider} />
```

`SignInMethodsContainer` no longer accepts `lastAuthMethod`, and the sign-in page
no longer reads the cookie on the server — the badge reads it through Better
Auth's `lastLoginMethodClient` and renders with `ssr: false`. That removes a
server cookie read *and* the hydration-mismatch risk.

### If you have customized the UI

Take your own version for any file you have restyled. Nothing in this step is
required by the rest of the migration.

### Validate Before Continuing

- [ ] Auth pages render correctly in light and dark mode
- [ ] Avatars are the shape you expect across the app
- [ ] Your own styling survived, or you deliberately took theirs

---

## After Upgrading

### Run all quality checks

```bash
pnpm install
pnpm typecheck
pnpm lint:fix
pnpm format:fix
pnpm test:unit
pnpm --filter web build
```

### Test core flows manually

Start the dev server and verify these flows work:

- [ ] Sign up / sign in / sign out
- [ ] Organization creation and switching
- [ ] Inviting members, and accepting an invitation from the emailed link
- [ ] Billing checkout and portal access
- [ ] Account settings and profile updates
- [ ] Navigation between authenticated pages shows content immediately

### Verify caching headers

Confirm the marketing site is cacheable and authenticated pages are not:

```bash
curl -sI https://your-app.com/         | grep -i 'cache-control'
curl -sI https://your-app.com/dashboard | grep -i 'cache-control'
```

Public pages should be shared-cacheable (`s-maxage`). Authenticated pages must be
`private, no-store`.

{% callout type="warning" title="Re-check this one after every change" %}
Marketing pages being publicly cacheable is only safe because Step 4 removed all
user data from their HTML. If you later add a server-side session read to a
marketing page, that page will be cached with one user's data and served to
everyone. Re-run the session-string scan from Step 4 whenever you touch the
marketing layout.
{% /callout %}

### Verify authentication once more

Because Step 6 moved auth gating into the proxy, this is worth an explicit pass:

```bash
for u in /dashboard /settings /settings/billing /admin; do
  echo "$u -> $(curl -s -o /dev/null -w '%{http_code}' https://your-app.com$u)"
done
```

- [ ] Every authenticated route returns `307` when signed out
- [ ] Any authenticated route group *you* added is listed in `isProtectedPathname`

### Lock in the behaviour with tests

`@next/playwright` ships an `instant()` helper that asserts on what is visible the
moment a navigation completes, so regressions fail CI instead of shipping. It is
not part of the kit — add it to `apps/e2e` first:

```bash
pnpm --filter web-e2e add -D @next/playwright
```

It is experimental and requires Cache Components, so only add it after Step 6:

```typescript
import { test, expect } from '@playwright/test';
import { instant } from '@next/playwright';

test('settings page header appears instantly', async ({ page }) => {
  await page.goto('/dashboard');

  await instant(page, async () => {
    await page.click('a[href="/settings"]');
    await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
  });
});
```

Add one for each route that matters most to you. Without them, the next person to
add an `await` at the top of a page silently undoes this migration.

### Clear your `instant = false` list

If you used `instant = false` to get through [Step 6](#6-cache-components),
revisit those routes now. Each one is a route that is not navigating instantly.

The kit itself ships zero. If you needed them for your own routes, treat them as a
to-do list rather than a permanent state.

### Recommendations

- **Use the Navigation Inspector.** Next.js DevTools → Navigation Inspector →
  "Pause on navigations" freezes the page at its initial state, so you see exactly
  what your users see before data streams in.
- **Push boundaries down over time.** Passing validation means navigation is
  instant, not that the loading state is good. Revisit pages and move
  `<Suspense>` closer to the data.
- **Re-check loading states by eye.** Every loading-state bug found during this
  migration was caught visually and none by a test. Automated checks pass far too
  easily here.
