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 vianext/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 v1From now on, pull updates exclusively from v1:
git pull upstream v1v2 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:
| # | 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.
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 cacheis not available until Step 6. The directive requirescacheComponents, 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 addinguse cachebefore 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, withuse cacheavailable. If you reorder these, you will either do the work twice or leaveinstant = falsescattered 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 mainBefore starting the migration
Make sure your main branch is up to date, and that typecheck, lint, and format run without errors:
git pull upstream mainpnpm typecheckpnpm lintpnpm formatIf 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.yamlpnpm installgit add pnpm-lock.yamlIf 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.
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 installAlways 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
- Root Layout
- Root Params + i18n
- Loading States
- Marketing Auth
- Suspense Boundaries
- Cache Components
- Static Content
- Partial Prefetching
- Design Updates
- After Upgrading
1. Root Layout
git checkout -b v2/root-layoutgit pull upstream v2-step/root-layoutpnpm installWhat 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 andapp/[locale]/layout.tsx is now the root layout, and app/not-found.tsx becameapp/global-not-found.tsx.Check that any customizations I had in app/layout.tsx (providers, scripts, headtags, classes) survived the move into app/[locale]/layout.tsx, and that nothingpage-like is left directly under app/ other than route handlers and metadatafiles.Validate Before Continuing
pnpm --filter web exec next typegencat apps/web/.next/types/root-params.d.tsExpected:
declare module 'next/root-params' { export function locale(): Promise<string>}- [ ]
root-params.d.tsdeclareslocale() - [ ]
/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].
next typegen writes to .next/types/, while next dev writes to .next/dev/types/. Checking the wrong one reads as a stale failure.
2. Root Params + i18n
git checkout -b v2/root-params-i18ngit pull upstream v2-step/root-params-i18npnpm installWhat 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.
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:
'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.
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 |
|---|---|---|
/ | en | 0 |
/es | es | present |
- [ ] The marker only appears under the second locale
- [ ]
grep -rn "requestLocale" apps packagesreturns onlyi18n/request.ts - [ ] Your build succeeds
Revert the locale and message files afterwards.
3. Loading States
git checkout -b v2/loading-statesgit pull upstream v2-step/loading-statespnpm installWhat 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.tsxsits above the internal layouts, so it stands in for the whole shell — butdashboard/rendersAppSidebarwhilesettings/rendersSettingsSidebar. A confident wrong guess across the whole viewport reads worse than a neutral indicator. - Invented content. The dashboard body is an
ssr: falsedynamic 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.
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.
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 typecheckpnpm --filter web build- [ ] Your custom
loading.tsxfiles still render - [ ] The built CSS contains
animation-delayandanimation-fill-mode:both - [ ] Navigating quickly between pages paints no skeleton at all
4. Marketing Auth
git checkout -b v2/marketing-authgit pull upstream v2-step/marketing-authpnpm installWhat 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:
- 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.
- Control the scan works — it must find your email on an authenticated page.
- 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 typecheckpnpm --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
userprop has been updated
5. Suspense Boundaries
git checkout -b v2/suspense-boundariesgit pull upstream v2-step/suspense-boundariespnpm installThis 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:
AppSidebarsuspends only the workspace dropdown. The organization list feeds that one widget; the frame, the navigation andchildrendo not need it.SettingsSidebarsuspends 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:
- A suspended region reserves no space. Wrapping a structural element means the page has no sidebar-shaped hole waiting for it.
- 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:
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 typecheckpnpm --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-componentsgit pull upstream v2-step/cache-componentspnpm installThis 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.
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.
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.
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.
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:
<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.
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
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:
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.
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:
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 buildgrep -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 = falsecount is as low as you can get it
7. Static Content
git checkout -b v2/static-contentgit pull upstream v2-step/static-contentpnpm installWhat 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 generateMetadatacache() 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.
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:
find apps/web/.next/server/app -name '*.html' | grep -c blogValidate Before Continuing
pnpm --filter web build- [ ] Every blog post, changelog entry and help page has prerendered HTML
- [ ] Content pages return
s-maxagerather thanno-store - [ ] No
cache()wrapper remains on anything markeduse cache
8. Partial Prefetching
git checkout -b v2/partial-prefetchinggit pull upstream v2-step/partial-prefetchingpnpm installWhat 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
pnpm --filter web buildgrep -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/designgit pull upstream v2-step/designpnpm installThis 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/avatarnow resolves to a makerkit wrapper rather than the shadcn primitive, so the rounded-square shape is applied once instead of by ad-hocrounded-md!overrides at each call site.src/shadcn/stays untouched so the shadcn CLI can still replace it.LastAuthMethodHintis 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 installpnpm typecheckpnpm lint:fixpnpm format:fixpnpm test:unitpnpm --filter web buildTest 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.
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.
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
307when 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/playwrightIt 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.