Migrate to Next.js Drizzle v2

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

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 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:

git checkout v1

From now on, pull updates exclusively from v1:

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.

- 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:

#TagWhat It Does
1v2-step/root-layoutMakes app/[locale]/layout.tsx the real root layout
2v2-step/root-params-i18nResolves the locale via next/root-params
3v2-step/loading-statesReplaces full-page spinners with considered loading states
4v2-step/marketing-authMakes marketing pages CDN-cacheable
5v2-step/suspense-boundariesAdds <Suspense> across the app
6v2-step/cache-componentsEnables cacheComponents
7v2-step/static-contentPrerenders and caches blog, changelog and help
8v2-step/partial-prefetchingEnables partialPrefetching
9v2-step/designVisual 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.

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:

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:

git pull upstream main
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:

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:

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

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:

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
  2. Root Params + i18n
  3. Loading States
  4. Marketing Auth
  5. Suspense Boundaries
  6. Cache Components
  7. Static Content
  8. Partial Prefetching
  9. Design Updates
  10. After Upgrading

1. Root Layout

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.

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

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

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

Expected:

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].


2. Root Params + i18n

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:

+ 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:

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.

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:

# 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
/en0
/esespresent
  • [ ] 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

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.

RouteTreatment
(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:

'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.

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

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

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

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

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:

- 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.

RegionFallback
Known shape (table, cards, page header)Matching skeleton
Unknown shape, large surfaceReserved space, no content
Fixed slot, resolves fastSmall 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:

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:

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

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

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

CauseFix
dynamic segment configsRemove — every page is dynamic by default now
Root param without generateStaticParamsAdd to [locale]/layout.tsx
Theme cookie read in the root layoutnext-themes owns the class
CSP nonce headers() readGate on ENABLE_STRICT_CSP
new Date() in the footerCache it
Auth gating in (internal)/layout.tsxMove to proxy.ts
usePathname in the site navigationSplit 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.

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.

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.

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

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:

InsightWhen it appears
blocking-prerender-dynamic — uncached data outside <Suspense>On a direct page load
instant-shell-url-dataparams/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:

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.

- 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

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

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:

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:

// 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.

Validate Before Continuing

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

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> propBeforeAfter
<Link href="/x">Prefetched the cached page renderLoads the shared App Shell
<Link href="/x" prefetch>Prefetched the page and its dynamic contentLoads the App Shell; per-link runtime data only if the route opts into runtime prefetching
<Link href="/x" prefetch={false}>DisabledUnchanged

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

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

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

- <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

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:

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.

Verify authentication once more

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

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:

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

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

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, 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.