Migrate to Next.js Supabase v4
A guide to updating this kit from v3 to v4 using git and AI Agents
The source for this page is available at docs/installation/v4-migration.mdoc. You can reference this file to AI agents for automatic migrations.
v4 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
- Skeleton loading states — route-shaped skeletons replace full-page spinners
- Statically generated content — blog, docs, and changelog 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.
This guide covers every breaking change and what you need to update if you customized the codebase.
Requirements
v4 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 v3 line before v4 was branched, because nothing about it is specific to v4 — so if you are up to date you already have it. Before starting the migration shows you how to check.
The catalog pins 16.3.0. If you keep your own pin, or track Next outside the catalog, make sure it resolves to 16.3 or later before you start — the failure mode is silent rather than loud, see below.
How long will it take?
If you haven't customized much, expect 2-4 hours — most steps are git pull
pnpm installwith few conflicts.
If you've heavily customized the app directory (custom pages that fetch data, custom layouts, a rewritten marketing site), expect 1-2 days. This upgrade touches how every page is structured, so the work scales with how many custom pages you have.
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 v3 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 v3 branch:
git checkout v3From now on, pull updates exclusively from v3:
git pull upstream v3v4 is a performance upgrade, not a functionality upgrade. Your app works fine on v3. 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 v3, 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 v4, 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 { account } = await params;- const members = await loadMembers(account); // blocks the entire page- return <PageBody><Header /><MembersTable members={members} /></PageBody>;- }+ function Page({ params }) {+ return (+ <PageBody>+ <Header />+ <Suspense fallback={<MembersTableSkeleton />}>+ <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
v4 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 | v4-step/root-layout | Makes app/[locale]/layout.tsx the real root layout |
| 2 | v4-step/root-params-i18n | Resolves the locale via next/root-params |
| 3 | v4-step/loading-states | Replaces full-page spinners with skeletons |
| 4 | v4-step/marketing-auth | Makes marketing pages CDN-cacheable |
| 5 | v4-step/suspense-boundaries | Adds <Suspense> across the app |
| 6 | v4-step/cache-components | Enables cacheComponents |
| 7 | v4-step/static-content | Prerenders and caches blog, docs, and changelog |
| 8 | v4-step/partial-prefetching | Enables partialPrefetching |
| 9 | v4-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-5 touch pages you have probably customized — those are where your time will go. Steps 6-8 are mostly configuration plus fixing whatever the dev overlay reports. 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, skeletons, and a cacheable marketing site all improve your app under the v3 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 with the v3 branch, and that typecheck, lint, and format run without errors.
If you're behind v3, update it:
git pull upstream v3Now make sure these commands run without errors:
pnpm 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 v3 line as an ordinary catalog refresh, because nothing about it is specific to v4 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 that it worked:
grep "^ next:" pnpm-workspace.yamlAnything 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.yamlThe same update carries a test-only fix worth knowing about if you have your own Stripe checkout tests: apps/e2e/tests/utils/stripe.po.ts now retries the checkout submit until the button reports it is processing. Stripe re-renders the embedded checkout while it validates the form, and a click that lands mid-render is dropped silently — the test then fails 20s later waiting on a success page that was never going to appear. Apply the same retry to your own tests.
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. v4/root-layout, v4/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 v4-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 6-8 change runtime rendering behaviour 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 v4/<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 the AI-assisted review using an AI coding agent (Claude Code, Cursor, etc.). Each step below includes a tailored prompt.
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
Create a branch and pull in the changes:
git checkout -b v4/root-layoutgit pull upstream v4-step/root-layoutpnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged `v4-step/root-layout`.This step deletes `apps/web/app/layout.tsx` so that`apps/web/app/[locale]/layout.tsx` becomes the true root layout. This isrequired for `next/root-params` to work. `apps/web/app/not-found.tsx` stays,but is rewritten to render without a root layout of its own.In my custom code:1. If I customized `apps/web/app/layout.tsx`, move those customizations into `apps/web/app/[locale]/layout.tsx`. Note the CSS import path changes from `../styles/globals.css` to `../../styles/globals.css`.2. If I customized `apps/web/app/not-found.tsx`, keep it but strip the <html>, <body>, theme and i18n provider wrappers — there is no root layout above it any more, so Next supplies the document shell. It must not render a page shell of its own. `apps/web/app/[locale]/not-found.tsx` handles 404s inside the locale tree and inherits <html>/<body> from the layout.3. Check for any page or layout files still directly under `apps/web/app/`. Only these may remain there: `global-error.tsx`, `robots.ts`, `sitemap.ts`, `api/`, and other route handlers. Everything else must move under `[locale]/`.4. Search for imports referencing the deleted files.Then run `pnpm typecheck`, start the dev server, and confirm that `/` returns200 and an unknown URL returns 404.Why this is needed
Root params are the dynamic segments that appear before the root layout.
In v3, apps/web/app/layout.tsx existed as a pass-through (return children) purely so that apps/web/app/not-found.tsx had 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.
Removing it promotes [locale]/layout.tsx to root layout and makes locale a root param. You can verify this after merging: Next generates the type definition during next dev or next typegen.
pnpm --filter web exec next typegencat apps/web/.next/types/root-params.d.tsYou should see:
declare module 'next/root-params' { export function locale(): Promise<string>}If it instead says No root params detected, something is still above the [locale] segment — check for a stray layout.tsx or page file directly under apps/web/app/. Also confirm you are on Next 16.3 or later; earlier versions do not emit this file at all.
Directory Structure
apps/web/app/- ├── layout.tsx ← deleted (was just `return children`) ├── not-found.tsx ← kept, for requests the proxy does not rewrite ├── [locale]/ │ ├── layout.tsx ← now the root layout, owns the CSS import │ └── not-found.tsx ← now handles all 404s ├── api/ ← unchanged ├── global-error.tsx ← unchanged ├── robots.ts ← unchanged └── sitemap.ts ← unchanged404 handling
There are two not-found files and they cover different traffic:
apps/web/app/[locale]/not-found.tsxhandles anything the proxy rewrote into the locale tree, which is most requests. It inherits the layout.apps/web/app/not-found.tsxhandles what the proxy skips — the paths excluded by its matcher, such asapi/*,sitemap.xmlandrobots.txt. With no root layout above it, Next provides the document shell, so this file must render only its own content.
Keeping a page-like file at app/ root does not reintroduce the root-layout problem on 16.3: next typegen still emits root-params.d.ts. Verify after this step rather than assuming — that file is the whole reason Step 2 works.
Validate Before Continuing
pnpm typecheckpnpm dev- [ ]
apps/web/.next/types/root-params.d.tsdeclareslocale() - [ ]
/returns 200 and renders with the correct<html lang> - [ ] An unknown URL returns 404 and renders your not-found page
- [ ] Your custom root layout changes survived the move
2. Root Params + i18n
Create a branch and pull in the changes:
git checkout -b v4/root-params-i18ngit pull upstream v4-step/root-params-i18npnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged`v4-step/root-params-i18n`.This step changes `apps/web/i18n/request.ts` to resolve the locale from`next/root-params` instead of reading `requestLocale`. Reading `requestLocale`calls `headers()`, which forces dynamic rendering everywhere and makesnext-intl unusable inside a `use cache` boundary.In my custom code:1. Find any custom code reading `requestLocale` in the i18n request config and remove it.2. Find any server component that receives `locale` as a prop — whether it feeds `getTranslations`, a data loader, or anything else. They can all call `locale()` from `next/root-params` directly. Remove the prop drilling, and the now-unused `params` types that went with it.3. `next/root-params` cannot be used in Client Components, Server Actions, or Route Handlers. If any of my custom code calls `getTranslations` or `getLocale` from those places, flag it — it needs an explicit locale passed in instead.Then run `pnpm typecheck` and verify translated pages still render in eachconfigured locale.What changed
getRequestConfig receives requestLocale as a lazy getter — it only calls headers() if you read it. v3 read it. v4 does not:
+ import { locale as localeRootParam } from 'next/root-params';- export default getRequestConfig(async ({ requestLocale }) => {- let locale = await requestLocale;+ export default getRequestConfig(async ({ locale: localeOverride }) => {+ let locale = localeOverride ?? (await localeRootParam()); if (!locale || !routing.locales.includes(locale as never)) { locale = routing.defaultLocale; } return { locale, messages: await loadMessages(locale) }; });Because the config never touches headers(), next-intl works inside cached components — which Step 6 depends on.
No more prop drilling
Any server component can now read the locale directly:
- async function DocsSidebar({ locale }: { locale: string }) {- const pages = await getDocs(locale);+ import { locale } from 'next/root-params';++ async function DocsSidebar() {+ const pages = await getDocs(await locale());Root param getters are also tracked inside use cache — only the params you actually read become part of the cache key.
Where root params do not work
next/root-params is Server Components only. It throws in:
- Client Components
- Server Actions
- Route Handlers
unstable_cache
The kit itself does not call next-intl from any of these. If your custom code does — for example rendering a localized email inside a Server Action — pass the locale in explicitly with getTranslations({ locale }).
setRequestLocale is not a substitute
setRequestLocale stores the locale in a React cache()-scoped object. That scope does not cross a use cache boundary, so it will not make next-intl work inside cached components. Resolving the locale in getRequestConfig is what does the work.
Validate Before Continuing
pnpm typecheckpnpm dev- [ ] Pages render translated content in the default locale
- [ ] Pages render translated content in a non-default locale (e.g.
/es) - [ ] Locale switching still works
- [ ] No
headers()/ dynamic rendering errors in the dev console
3. Loading States
Create a branch and pull in the changes:
git checkout -b v4/loading-statesgit pull upstream v4-step/loading-statespnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged `v4-step/loading-states`.This step replaces full-page spinners in `loading.tsx` with route-shapedskeletons.In my custom code:1. Find `loading.tsx` files that render a full-page spinner and replace them with a skeleton that matches the shape of the page underneath — same header, same layout, placeholder blocks where content goes.2. Reuse the building blocks in `apps/web/components/skeletons`, or the `Skeleton` component from `@kit/ui/skeleton` for anything bespoke.3. Note where the `loading.tsx` sits. One inside a layout only replaces the page area, so the sidebar and navigation stay on screen and the skeleton should not redraw them. One above a layout has to stand in for the whole shell.4. Keep the skeleton cheap. It renders on every navigation and must not fetch data.Show me each replacement so I can check the skeleton matches my page layout.Why
A full-page spinner throws away everything the user was looking at. A skeleton that matches the page keeps the layout stable and makes the wait feel shorter, even when it isn't.
This step is cosmetic and safe to merge on its own. It also sets you up for Step 5, where the same skeleton components become <Suspense> fallbacks — which is why the shared pieces live in components/skeletons rather than inline in each loading.tsx.
Validate Before Continuing
- [ ] Navigating to each route shows a skeleton matching that page
- [ ] No layout shift when real content replaces the skeleton
- [ ] Skeletons do not fetch data
4. Marketing Auth
Create a branch and pull in the changes:
git checkout -b v4/marketing-authgit pull upstream v4-step/marketing-authpnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged `v4-step/marketing-auth`.This step removes `export const dynamic = 'force-dynamic'` from the marketinglayout and stops fetching the user on the server there, so marketing pages canbe cached by a CDN.IMPORTANT: `force-dynamic` was there for a security reason. It prevented a CDNfrom caching a page containing one user's data and serving it to another user.Do not simply remove `force-dynamic` from your own routes without applying thesame restructure.In my custom code:1. Find any marketing/public page or layout that renders user-specific data (email, name, avatar, account info) from a server-side fetch. That data must move to a client component, or behind a Suspense boundary that is never part of the cached shell.2. Find other uses of `export const dynamic = 'force-dynamic'`. For each one, tell me WHY it is there before changing anything. If it guards user data, restructure. If it was cargo-culted, it can be removed — under Cache Components everything is dynamic by default.3. Verify no public, cacheable page renders user identity on the server.4. `useUser()` now returns `null` for signed-out visitors instead of `undefined`. If my code compares against `undefined`, or relies on `data === undefined` to mean "signed out", update it.List everything you find before making changes.Read this section even if you didn't customize anything
In v3, the marketing layout fetched the signed-in user on the server so the header could show "Dashboard" instead of "Sign in". That put a real user's data into HTML that a CDN could cache and serve to somebody else. force-dynamic was the fix: it disabled caching entirely.
That worked, but it made the entire public site — homepage, pricing, FAQ, blog, changelog, legal pages — uncacheable and dependent on an auth round-trip.
v4 fixes the cause instead. The marketing header now resolves auth state on the client, so the server-rendered HTML contains no user data at all. The page becomes safe to cache because there is nothing user-specific in it — not because a config flag says so.
- export const dynamic = 'force-dynamic';-- async function SiteLayout(props: React.PropsWithChildren) {- const client = getSupabaseServerClient();- const user = await requireUser(client, { verifyMfa: false });-- return <SiteHeader user={user.data} />;+ function SiteLayout(props: React.PropsWithChildren) {+ return <SiteHeader />; // resolves auth client-sideThe trade-off is a brief skeleton on one header button on first paint. In exchange the whole marketing site becomes edge-cacheable.
useUser() now returns null when signed out
It previously returned undefined, which React Query rejects with "Query data cannot be undefined". That never surfaced in v3 because every caller sat on an authenticated page; the marketing header is the first anonymous one.
If your code treats undefined as "signed out", switch to null — or just check falsiness, which works for both.
Verify it yourself
Do not take this on trust — the failure mode is silent and only shows up in production caches. Sign in, then check that a real session changes nothing about the server-rendered HTML:
# 1. sign in and capture the session cookie from your browser devtoolsCOOKIE='sb-...-auth-token=...'# 2. an authenticated page SHOULD contain your email — this proves the grep workscurl -s -H "Cookie: $COOKIE" http://localhost:3000/home | grep -c 'you@example.com'# 3. a marketing page MUST NOTcurl -s -H "Cookie: $COOKIE" http://localhost:3000/pricing | grep -c 'you@example.com'Step 2 matters as much as step 3: a grep that finds nothing anywhere proves nothing. Confirm it can detect a leak before trusting that it found none.
For a stronger check, diff the two responses:
curl -s http://localhost:3000/pricing > anon.htmlcurl -s -H "Cookie: $COOKIE" http://localhost:3000/pricing > auth.htmldiff <(sed -E 's/self\.__next_r="[^"]*"/X/' anon.html) \ <(sed -E 's/self\.__next_r="[^"]*"/X/' auth.html)Apart from Next's per-request render id and RSC stream row numbers, the two should be identical — the server output should not depend on auth at all.
If you cache these pages yourself
If you put your own CDN in front of the app, two things matter:
- Next varies responses on
rsc,next-router-prefetch,next-router-state-tree, andnext-router-segment-prefetch. Your CDN must either honourVaryor include the_rscquery parameter in its cache key. Getting this wrong is how cached variants get crossed between users. proxy.tsmust run before the CDN cache, since it is the source of truth for auth, redirects, and rewrites. If your CDN sits in front of it, bypass the cache for routes that depend on proxy decisions.
Verify with real requests before trusting it:
curl -sI https://your-app.com/pricing | grep -i 'cache-control\|vary'A public marketing page should show a shared-cache directive (s-maxage). Anything under /home should show private, no-store. If a page under /home is publicly cacheable, stop and investigate.
Validate Before Continuing
- [ ] Marketing pages render correctly signed out
- [ ] Marketing pages show the right header state signed in — check this in a browser, since the swap happens on the client and
curlcannot see it - [ ] No user data (email, name, ID) appears in the server-rendered HTML of a public page — check with
curl, not the browser - [ ] Your grep can actually detect a leak (it finds the email on
/home) - [ ] No "Query data cannot be undefined" errors in the browser console
- [ ] Authenticated pages still return
private, no-store
5. Suspense Boundaries
Create a branch and pull in the changes:
git checkout -b v4/suspense-boundariesgit pull upstream v4-step/suspense-boundariespnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged`v4-step/suspense-boundaries`.This step restructures pages so data fetching happens inside <Suspense>boundaries instead of at the top of the component.For each of my custom pages and layouts under `apps/web/app/[locale]/`:1. Find components that `await params`, `await searchParams`, or await a data fetch at the top level before returning JSX.2. Restructure: keep the page component synchronous, pass the params/ searchParams PROMISE down (do not await it), and await it inside a child wrapped in <Suspense>.3. Put the boundary as low as possible. Everything outside it renders immediately, so keep headers, breadcrumbs, and card chrome outside.4. Use the skeleton components from step 3 as fallbacks.5. Do not wrap the whole page in one <Suspense> — that just recreates the full-page spinner.6. BREAKING: `useUserWorkspace()` and `useTeamAccountWorkspace()` now suspend. Find every component of mine that calls either, and make sure a <Suspense> boundary sits above it. Their return type has not changed, so this will not show up as a type error — it shows up as a page that suspends higher than I intended.Work one page at a time and show me each before/after.Breaking: the workspace hooks now suspend
The workspace contexts hold a promise instead of resolved data, and useUserWorkspace() / useTeamAccountWorkspace() unwrap it with use().
This is what lets the layouts render. Previously the provider took resolved data and wrapped {children}, so nothing below could render until the workspace loaded — including pages that never read it.
- async function TeamWorkspaceLayout({ children, params }) {- const data = await loadTeamWorkspace(account); // everything waits- return (- <TeamAccountWorkspaceContextProvider value={data}>- <Sidebar {...data} />- {children}- </TeamAccountWorkspaceContextProvider>- );+ function TeamWorkspaceLayout({ children, params }) {+ const workspace = loadTeamWorkspace(account); // promise, not awaited+ return (+ <TeamAccountWorkspaceContextProvider value={workspace}>+ <Suspense fallback={<SidebarSkeleton />}>+ <TeamAccountSidebar account={account} />+ </Suspense>+ {children} {/* renders immediately */}+ </TeamAccountWorkspaceContextProvider>+ );The hooks' return type is unchanged, so your code still compiles. What changes is timing: a component calling either hook now suspends to the nearest boundary above it. If there isn't one, it suspends further up than you want — possibly taking the whole page with it.
Audit every caller and give each one a boundary.
The pattern
- async function MembersPage({ params }: Props) {- const slug = (await params).account;- const [members, invitations] = await loadMembersPageData(client, slug);-- return (- <PageBody>- <PageHeader title="Members" />- <MembersTable members={members} />- <InvitationsTable invitations={invitations} />- </PageBody>- );- }+ function MembersPage({ params }: Props) {+ return (+ <PageBody>+ <PageHeader title="Members" />+ <Suspense fallback={<MembersTableSkeleton />}>+ <MembersTable params={params} />+ </Suspense>+ <Suspense fallback={<InvitationsTableSkeleton />}>+ <InvitationsTable params={params} />+ </Suspense>+ </PageBody>+ );+ }The header now paints immediately, and the two tables resolve independently instead of the slower one holding up the faster one.
Boundary placement matters
Placing one <Suspense> around the whole page technically satisfies the validator in Step 6, but it replaces the entire page with a fallback on every navigation — no better than the v3 spinner.
Keep as much real content outside the boundary as you can. A page that keeps its header, navigation, and card structure visible with only a table behind a fallback feels dramatically faster than a full-page skeleton, even at identical total load time.
Suspend the smallest region that actually needs the data
Split components before wrapping them. Much of what looks data-dependent is not.
The sidebar is the clearest example. It looks like it needs the workspace, so the obvious move is to wrap the whole thing:
- <Suspense fallback={<SidebarSkeleton />}>- <TeamAccountLayoutSidebar account={account} />- </Suspense>That is worse than not doing it at all. The sidebar is then absent from the first paint and pushes the layout when it arrives, because a suspended region reserves no space.
In fact only the header widget — the account dropdown and notifications — reads the workspace. The frame and every navigation item come from config derived from the account slug. So the boundary belongs inside the sidebar:
<Sidebar> {/* renders immediately */} <SidebarHeader> <Suspense fallback={<SidebarHeaderSkeleton />}> <TeamAccountSidebarHeader account={account} /> </Suspense> </SidebarHeader> <SidebarContent> <SidebarNavigation config={config} /> {/* renders immediately */} </SidebarContent></Sidebar>Now the sidebar and its links are on screen from the first byte, and a small avatar-sized placeholder fills in.
Two rules follow:
- A suspended region reserves no space. If the fallback does not occupy the same footprint as the real content, the page shifts when it resolves. Prefer moving the boundary inward over drawing a bigger fallback.
- Structural fallbacks should not be delayed. The delay from Step 3 exists to avoid flicker in content areas. In a fixed slot that is always occupied there is no flicker to avoid — delaying just leaves it blank and then pops in.
When you can't predict the shape, reserve the space
Not every region deserves a skeleton, but fallback={null} is not the answer either — it lets the container collapse, so the page jumps as content arrives.
Content whose shape you can't predict — CMS-driven listings, long-form articles — gets a fallback that holds the height and draws nothing:
<Suspense fallback={<ContentFallback />}> <BlogPosts searchParams={searchParams} /></Suspense>ContentFallback is a min-h-[60vh] container with a spinner that only appears after 400ms. Fast navigations show reserved space and nothing else; slow ones get honest feedback. No invented cards, no collapsing layout.
The kit uses this for blog, changelog and docs. A grid of grey cards there mispredicts posts badly — images and headline lengths vary far too much — and on a public page it reads as cheap in a way it does not inside an app.
So there are three answers, not two:
| Region | Fallback |
|---|---|
| Known shape (table, cards, page header) | Matching skeleton |
| Unknown shape, large surface | Reserved space, no content |
| Fixed slot, resolves fast (a header widget) | Small skeleton, no delay |
How to tell it actually worked
A page can render correctly and still be fully blocking, so check the stream rather than the finished page. Fetch a route and confirm the fallback markup and the page content are both present in the same response:
curl -s -H "Cookie: $COOKIE" http://localhost:3000/home/your-team/members \ | grep -c 'data-slot="skeleton"'A hit means the boundary is real and the rest of the document was flushed without waiting. Zero means everything still resolved server-side before the first byte — the boundary is in the wrong place, or nothing below it actually suspends.
Validate Before Continuing
- [ ] Each page paints its header and layout immediately on navigation
- [ ] Only data-dependent regions show a fallback
- [ ] No page is entirely replaced by a skeleton
- [ ] Data still loads correctly after streaming in
- [ ] Every caller of
useUserWorkspace/useTeamAccountWorkspacehas a<Suspense>boundary above it - [ ] Fallback markup appears in the streamed HTML, not just in your JSX
- [ ] Nothing shifts position when a boundary resolves — navigate with the network throttled and watch for content being pushed around
6. Cache Components
Create a branch and pull in the changes:
git checkout -b v4/cache-componentsgit pull upstream v4-step/cache-componentspnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged`v4-step/cache-components`. This enables `cacheComponents: true`.This is the step most likely to break my custom code. In my custom code:1. Remove `export const dynamic = 'force-dynamic'` — everything is dynamic by default now. (Check first that it is not guarding user data — see step 4.)2. Remove `export const dynamic = 'force-static'`. Replace with `use cache` plus a long `cacheLife` on the data access.3. Remove `unstable_noStore()` / `noStore()` — nothing is cached unless I opt in. If a component must run per request, call `connection()` and wrap it in <Suspense>.4. Replace `unstable_cache` with `use cache`.5. `export const runtime = 'edge'` is not supported. Move to the Node runtime.6. In Server Actions, replace `revalidateTag(tag)` with `updateTag(tag)` when the user must see their own change immediately. In Route Handlers and webhooks, `revalidateTag` now REQUIRES a cache profile as a second argument: `revalidateTag(tag, 'max')`.7. `use cache` is available from this step onward. Add it to CMS and content loader functions with an appropriate `cacheLife` profile.8. Do NOT add plain `use cache` to anything that reads `cookies()`, `headers()`, or the Supabase server client — that client marks its caller request-bound. Those need `use cache: private` (per-session, browser-only) or a <Suspense> boundary instead.9. Run `pnpm build`. When an error names no component, re-run with `next build --debug-prerender` for a real stack trace. Fix the TOPMOST cause and rebuild before working through the list — failures cluster, and one root cause often accounts for every failing route.10. A green build is not the end. Start the dev server and click through every route: the overlay also validates client navigations, which the build does not check. Expect a second round of fixes here.11. Look for shared components reading `cookies()` or `headers()` — a page header, a nav item. Each one makes every page that renders it dynamic. Derive the value from context or props instead.12. If a fix would touch many call sites, check whether it can be made once further down. Caching one loader often clears dozens of routes.For any route I can't fix right now, add `export const instant = false` to itspage or layout and tell me which routes you did that to, so I can come back.The build is not enough — walk the app in dev
cacheComponents errors at build time on uncached data access it can't resolve. Unlike the dev-only navigation insights, this is not a warning.
But a green build does not mean you are done. The build validates prerendering; the dev overlay additionally validates client navigations, and catches things the build never sees. Expect a second round of fixes after the build passes.
Do not find them by clicking around. A blocking route is valid — it just is not instant — so nothing fails, and a manual walk quietly skips routes you do not think to visit. Sweep them systematically instead; see Sweep every route below.
Use next build --debug-prerender when an error names no component — it turns "Route X encountered uncached data" into an actual stack trace.
Expect one root cause behind many routes
Failures cluster. In this kit, 35 of 35 failing routes traced to two lines in the root layout. Fix the topmost cause and re-run before working through the list — most of it disappears.
The order they surfaced here, which is roughly the order to expect:
- Route segment configs —
dynamic = 'force-dynamic' | 'force-static'are hard incompatibilities. Compile error, trivial fix. - Root params —
generateStaticParamsis required for[locale], or the build fails. - Runtime data in the root layout — anything reading
cookies()orheaders()there blocks every route. - Unstable values in client components —
Date.now()during render, including inside React Query internals. generateMetadata— uncached I/O, usuallygetTranslations().- Client hooks reading the URL —
usePathname,useSearchParams.
Two fixes worth copying
Cache at the source, not the call site. 21 generateMetadata functions failed here because each awaited getTranslations(). Rather than adding use cache to all 21, cache the message loading in i18n/request.ts once:
async function loadMessages(locale: string) { 'use cache'; cacheLife('max'); // ...}Derive from context, not cookies. A shared page-header component read the layout-style cookie to decide whether to show the sidebar trigger — making every page that used it dynamic. The layout already knows its style, so the trigger now reads it from React context and the cookie read is gone.
Look for shared components reading cookies(); each one poisons every page that renders it.
Unstable values are not fixed by <Suspense>
Next.js encountered the unstable value `Date.now()` while prerendering.Uncached data and unstable values are different problems. A boundary lets Next stop and stream; an unstable value is an error, because the same render would produce a different answer next time. Adding <Suspense> will not clear it.
Pick by what the value is:
| The value is | Fix |
|---|---|
| Genuinely per-request — session, nonce, "now" for this visitor | await connection() before it |
| The same for everyone for a while — copyright year, price list | use cache + cacheLife |
| Only meaningful in the browser | 'use client' |
| Telemetry | performance.now() |
Two things that cost time here:
It must be awaited. During a prerender connection() returns a promise that never resolves — that is how it stalls. void connection() reads as equivalent and does nothing, unlike void cookies().
It belongs before the line that touches the value, which is earlier than the stack suggests. Any auth call reads Date.now() to check whether the session has expired, so requireUser, getUser and isSuperAdmin all trip it — not just the obvious ones.
The stack names the library, not your code — you'll see GoTrueClient and a component several levels up. Work outwards to the first thing in that subtree that builds a client or reads a clock.
Prefer the shared loader over the page. requireUserInServerComponent and the two workspace loaders cover most authenticated routes here, so fixing those three clears the bulk of it in one edit.
They do not cover everything, though — plenty of pages and loaders build a client themselves and never go through them. Those need their own await connection(). To find yours:
grep -rl "getSupabaseServerClient" apps/web/app --include="page.tsx" \ --include="layout.tsx" --include="*.loader.ts"Only render paths matter. Server actions and route handlers always run at request time, so they can never be prerendered and need nothing.
Client queries must be seeded
React Query is not hydrated from the server by default. Any component gated on a client query — useUser(), usePersonalAccountData() — renders its loading state during SSR and the real content on the client. That surfaces as empty cards and hydration mismatches, not as a validator error.
This step adds QueryHydration in the authenticated layouts to seed the cache from data the server already loaded. If you have your own client queries that gate rendering, seed them the same way or pass initialData.
Auth gating moves to the proxy
Pages that read the session to redirect can't prerender. Moving that gate into proxy.ts fixes it and improves the UX — the redirect happens before any HTML is sent, so there's no flash of a page the user was never meant to see.
Pages keep their own checks as defence in depth, behind a boundary.
Gate the page, not the whole subtree. A pattern like /join/*? also matches /join/accept — a route handler that has to stay reachable while signed out, because it is the entry point for emailed invite links and mints the session itself. Gating it redirects the user to sign-up before the handler ever runs, and silently breaks the invitation flow. Match the page exactly:
- pattern: new URLPattern({ pathname: '/join/*?' }),+ pattern: new URLPattern({ pathname: '/join' }),Check every pattern you add against the route handlers that live beneath it.
One behaviour change to be aware of: a request for a team you don't belong to now returns 200 with a shell and a streamed redirect, rather than a 307. No data is exposed — RLS still applies and the response contains no team data — but update any monitoring or e2e tests asserting the status code.
The escape hatch is per-route:
// app/[locale]/some-custom-route/page.tsxexport const instant = false;This opts the segment out of validation. Use it freely to get unblocked, keep a list, and come back. A route with instant = false still works — it just isn't guaranteed to navigate instantly.
This step should be almost mechanical
If steps 3-5 went well, turning the flag on is a small change: remove the incompatible segment configs, add generateStaticParams for your root locale param, and fix whatever the dev overlay still reports.
That is the point of the ordering. Step 5 put your data access behind <Suspense>, which is exactly what Cache Components validates for. Routes that already stream will pass; routes that still await at the top will not.
use cache becomes available from this step onward, but resist adopting it here beyond what the build demands — Step 7 does that in one pass over the content routes.
Revalidation changes
| v3 | v4 |
|---|---|
revalidateTag('posts') in a Server Action | updateTag('posts') |
revalidateTag('posts') in a Route Handler | revalidateTag('posts', 'max') |
revalidatePath('/home') | unchanged |
updateTag gives read-your-own-writes: the next request waits for fresh data rather than serving stale content. It can only be called from a Server Action.
UI state is now preserved across navigation
Cache Components enables React's <Activity>, so the previous route stays mounted and hidden instead of unmounting. Navigating back restores its state.
This is usually an improvement, but it changes behaviour for anything that assumed unmount-on-navigate. Test dialogs, dropdowns, and any component holding transient state. If a dialog reopens with stale state after navigating away and back, that is this change.
The common case is a dialog whose action ends in redirect(). Under v3 the navigation unmounted it, so nobody bothered to close it. Now it stays open on the route you left, and reappears — backdrop and all — when you come back. Close it explicitly on the navigation:
const { execute } = useAction(createTeamAccountAction, { onSuccess: ({ data }) => { /* ... */ },+ // The action redirects, which no longer unmounts this dialog.+ onNavigation: () => props.onClose?.(), });Audit every dialog whose submit handler redirects. This kit had one.
Your e2e tests will break, and the failure is misleading
A retained route keeps its DOM, so every data-test selector can now match twice — once on the page you are on, once on the page you left. The stale copy has zero size, so nothing looks wrong in a screenshot.
Playwright reports this in two very different ways depending on the API:
| API | Behaviour with 2 matches |
|---|---|
page.locator(...), getByTestId(...) | strict-mode violation, names both elements |
page.click(...), page.fill(...) | silently picks the first, which may be the invisible one, then waits for it to become visible until the test times out |
The second is the one that costs you an afternoon: a 120-second timeout with no error message, on a page that looks perfectly fine.
Scope the selector to the live route:
- await page.click('[data-test="workspace-dropdown-trigger"]');+ await page.click('[data-test="workspace-dropdown-trigger"]:visible');Apply it to container selectors too — scoping the dialog is enough to disambiguate everything inside it.
Sweep every route before you call this done
The routes that block are rarely the ones you suspect. In this kit a sweep found four that a clean next build --debug-prerender had passed, on pages nobody thought to re-check: a profile tab, an admin detail page, and the two billing return pages.
Enumerate routes from the running server, not from memory. Next 16 exposes an MCP endpoint on the dev server; get_routes is authoritative and includes entry points a find over page.tsx misses. get_compilation_issues covers every route without a browser.
Then load each one in a browser, signed in as the role it needs, and collect console errors matching Next.js encountered / uncached data / unstable value. Give dynamic segments real values — a seeded slug, a real content id, a plausible ?session_id=x.
Assert you actually landed on the route you asked for. This is the trap. If a session has expired, every authenticated route silently redirects to sign-in — and the sign-in page is clean. You then audit it fifteen times and conclude the app is healthy. That happened here: a sweep reported "31 of 33 clean" while four routes were broken. Fail loudly on a redirect instead:
const landed = new URL(page.url()).pathname;if (signedIn && landed.startsWith('/auth/sign-in')) { throw new Error(`NOT AUDITED: ${route} bounced to ${landed}`);}Keep the whole stack for anything you find. The first line names the framework; the line that names your code is further down. That is how isSuperAdmin and GoTrue were identified here.
Rerun this sweep whenever you add routes. It is the only check that catches this class, and it takes a couple of minutes.
Validate Before Continuing
pnpm typecheckpnpm build- [ ] Build completes
- [ ] You swept every route, not just the ones you remembered, and each loads without dev overlay errors or is explicitly
instant = false - [ ] No route in that sweep was silently redirected to sign-in and counted as clean
- [ ] Dialogs and dropdowns behave correctly across navigation, including after an action that redirects
- [ ] Your e2e suite passes, with selectors scoped to the visible route
- [ ] No proxy pattern gates a route handler that must work signed out
- [ ] Mutations still show updated data immediately
- [ ] You have a written list of every
instant = falseyou added
7. Static Content
Create a branch and pull in the changes:
git checkout -b v4/static-contentgit pull upstream v4-step/static-contentpnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged `v4-step/static-content`.This step enumerates the content routes (blog, docs, changelog) with`generateStaticParams` AND caches their loaders with `use cache`, in one pass.`cacheComponents` was enabled in step 6, so `use cache` is available now.In my custom code:1. Find custom content routes with dynamic segments that have no `generateStaticParams`. Add one.2. Return at least one param from every `generateStaticParams`. Returning `[]` is a build error under Cache Components (`empty-generate-static-params`). If I can't enumerate everything, return a subset — the rest are still served, just rendered on first visit.3. Add `'use cache'` to the CMS/content loader functions those routes call, with an appropriate `cacheLife` profile. After step 5 these already sit inside <Suspense>, so this pulls them back out of the fallback and into the prerendered shell.4. Do NOT add plain `use cache` to anything reading `cookies()`, `headers()`, or the Supabase server client — that client marks its caller request-bound. Those need `use cache: private` or stay behind their Suspense boundary.5. Content from the filesystem or a CMS is a good candidate. Anything per-user or per-account is not — leave those alone.Then run `pnpm build` and confirm the content routes prerender (○ rather than ƒin the route table).What changed
Content routes previously rendered on every request. They are now enumerated at build time and their data is cached, in a single edit per route:
+ export async function generateStaticParams() {+ const client = await createCmsClient();+ const { items } = await client.getContentItems({ collection: 'posts' });+ return items.map((item) => ({ slug: item.slug }));+ } async function getPostBySlug(slug: string) {+ 'use cache'; const client = await createCmsClient(); return client.getContentItemBySlug({ slug, collection: 'posts' }); }This is why the step sits after Cache Components. Enumerating the routes before the flag exists would mean coming back to add use cache to the same functions later — the same files, edited twice.
Remove cache() wrappers from anything you cache
React's cache() and use cache do not compose. Wrapping a use cache function in cache() fails the build with a misleading error:
TypeError: Invalid value used as weak map key at generateMetadatacache() keys a WeakMap on the function identity, which the use cache transform invalidates. Drop the wrapper — use cache already dedupes, and does it across requests rather than only within one:
- const getPostBySlug = cache(postLoader);-- async function postLoader(slug: string) {+ async function getPostBySlug(slug: string) {+ 'use cache';+ cacheLife('hours'); const client = await createCmsClient(); return client.getContentItemBySlug({ slug, collection: 'posts' }); }Every content loader in the kit used cache(), so expect to hit this on each one you convert.
Where use cache can go
- Plain
use cache— data that doesn't depend on the request: CMS content, config, public catalogues. use cache: private— per-session data. Reads runtime APIs directly and caches in the browser only, never on the server.
getSupabaseServerClient() marks its caller request-bound as soon as it is created, so anything using it can never sit inside a plain use cache. Either extract the value it needs and pass that into a cached function, or use use cache: private.
Return at least one param
Under Cache Components, returning [] raises empty-generate-static-params and fails the build. Paths you don't return are still served — Next serves the App Shell immediately, then upgrades it in the background once the params are known.
Validate Before Continuing
pnpm build- [ ] Build completes without
empty-generate-static-paramserrors - [ ] Blog, docs, and changelog appear as static (○) in the route table
- [ ] Blog, docs, and changelog pages render correctly
- [ ] Your custom content routes still work
8. Partial Prefetching
Create a branch and pull in the changes:
git checkout -b v4/partial-prefetchinggit pull upstream v4-step/partial-prefetchingpnpm installThen run the AI-assisted review with this prompt:
I'm upgrading Makerkit from v3 to v4. I just merged`v4-step/partial-prefetching`. This enables `partialPrefetching: true`.With this on, every <Link> prefetches a shared per-route App Shell instead ofa per-link full prefetch. `<Link prefetch={true}>` no longer prefetches thedestination's dynamic content.In my custom code, find every `<Link prefetch={true}>` and decide perdestination:- Destination is static or already cached -> remove `prefetch={true}`, it's redundant.- Destination had uncached content I want ready before the click -> cache it with `use cache`, then remove `prefetch={true}`.- Destination depends on cookies()/headers() -> cache the lookup behind the session value, then remove `prefetch={true}`. Session data is already in the App Shell.- Destination reads params/searchParams and I want that resolved before the click -> KEEP `prefetch={true}` and add `export const prefetch = 'allow-runtime'` to the destination.- Destination is real-time -> remove `prefetch={true}`, let it stream.Also flag any list or grid rendering many <Link prefetch={true}> to the same`allow-runtime` route — that costs one server render per visible link. Suggesthover-triggered prefetch there instead.What changes for <Link>
| Before | After | |
|---|---|---|
<Link href="/x"> | Prefetched the cached page render | Loads the shared App Shell for /x |
<Link href="/x" prefetch> | Prefetched page and dynamic content | Loads the App Shell; adds per-link data only if /x sets prefetch = 'allow-runtime' |
<Link href="/x" prefetch={false}> | Disabled | Unchanged |
The App Shell is shared by every link to a route, so rendering many links to the same destination no longer multiplies prefetch work.
Runtime prefetching costs a server render per link
prefetch = 'allow-runtime' resolves params and searchParams at prefetch time. It costs one server invocation per visible link, so use it only where the destination genuinely depends on URL data and the traffic justifies it.
Skip it when the route barely reads URL data, when the content must be fresh on every request, or when the route is rarely visited — you pay per visible link regardless of click-through.
The kit uses allow-runtime on nothing, and probably neither should you
The obvious candidate is /home/[account]/* — the slug is URL data and the workspace lookup depends on it. It still doesn't qualify.
A runtime prefetch only helps if the URL-dependent content is cacheable. It renders the route again with the URL resolved, advancing through anything static or use cache-backed, then stops at the first uncached read. Per-account data here comes from RLS-scoped Supabase queries, which are request-bound and can never sit inside a plain use cache. The prerender would stop at exactly the same <Suspense> boundary the App Shell already stops at — you'd pay a server render per visible link to arrive at the same UI.
The content routes don't need it either: they're prerendered via generateStaticParams from Step 7, so their params are already resolved at build time.
Before reaching for allow-runtime, ask whether the data behind the URL can actually be cached. If it can't, the flag buys nothing.
Validate Before Continuing
- [ ] Navigation between routes feels immediate
- [ ] No dev console errors about links prefetching dynamic data
- [ ] Long lists of links don't cause a burst of server requests
- [ ] Every remaining
prefetch={true}points at anallow-runtimeroute
9. Design Updates
Create a branch and pull in the changes:
git checkout -b v4/designgit pull upstream v4-step/designpnpm installThis step is different
Every other step changes how the app renders. This one only changes how it looks — no data fetching, no caching, no route structure. That has two consequences:
- It is optional. Skip it entirely if you have your own design. Everything in steps 1-8 works without it.
- It also cuts the release. Steps 1-8 ship as
4.0.0-rc.N; this step sets4.0.0. If you skip it you stay on4.0.0-rc.8— the app is complete and supported, you just bump the version yourself whenever you are ready. - It is the highest-conflict step in the migration, because it touches the components you are most likely to have restyled. If you have a custom design system, take the diff as a reference rather than merging it wholesale.
Because it is purely cosmetic, there is no AI-assisted prompt for this step — an agent cannot tell which of your visual changes were deliberate.
What changed
<!-- TODO: still growing as the design work lands. Add component-level changes here as they are decided — spacing scale, card treatment, typography, sidebar chrome. -->
Avatars are rounded squares, everywhere
v3 used the upstream shadcn avatar, which is a circle. v4 renders every avatar as a rounded square — people and workspaces alike — so a personal account and a team account read as the same kind of object in the workspace switcher.
The shape is applied by a wrapper, not by editing the primitive:
packages/ui/src/makerkit/avatar.tsxwrapsAvatar,AvatarImage,AvatarFallbackandAvatarGroupCountwithrounded-mdpackages/ui/package.jsonpoints the"./avatar"export at that file instead of./src/shadcn/avatar.tsx
src/shadcn/avatar.tsx stays byte-identical to upstream so the shadcn CLI can still replace it — the same wrap-and-re-export pattern already used for FormMessage. Call sites keep importing @kit/ui/avatar and need no change.
Two details worth copying if you port this to another kit:
- The primitive rounds four surfaces independently — the root, its
after:border ring, the image and the fallback. Overriding only the root leaves a circular ring around a square avatar. AvatarBadgestays round. It is a status dot, not an avatar.
If you want to keep circles, do not take the package.json export change — that single line is the whole switch.
One avatar component per workspace row
apps/web/components/workspace-dropdown.tsx previously rendered the personal account as a bare bordered div with a user glyph, and teams as an Avatar with a filled initial — different shape, different surface. Both now go through a local WorkspaceAvatar, which branches only on glyph-vs-initial.
The same defect exists in packages/features/accounts/src/components/account-selector.tsx (PersonalAccountAvatar renders a bare icon). That component has no call sites in the kit — if you use it, apply the same fix.
Last-used sign-in method hints
The auth pages now show a "Last used" badge on the method you signed in with last, stored under the auth_last_method key in localStorage (packages/features/auth/src/utils/last-auth-method.ts, 30-day expiry).
The rule for where to record it depends on where the sign-in completes:
| Method | Record at |
|---|---|
| Password, passkey | after the mutation resolves — the session lands in the same page |
| OAuth | before calling signInWithOAuth — it calls window.location.assign internally, so anything after it races the navigation |
| Magic link, OTP | when the email is sent — the sign-in may complete in /auth/confirm on a different page load |
If you add your own auth method, follow the row that matches how it completes.
The magic link template carries the OTP code
apps/web/supabase/templates/magic-link.html now renders both the sign-in button ({{ .TokenHash }}) and the code ({{ .Token }}).
Supabase has no separate OTP template — signInWithOtp sends the magic_link template for both flows, so a link-only template leaves OTP users with no code to type. The standalone otp.html is still there if you want a code-only email; point [auth.email.template.magic_link] at it in config.toml.
This is not just a file change: config.toml only applies locally. Paste the same HTML into Authentication → Templates → Magic Link in the Supabase Dashboard for each hosted project, or your production OTP emails stay code-less.
If you have customized the UI
- Merge the tag and expect conflicts in
packages/uiand your marketing pages. - Resolve in favour of your styling wherever you have deliberately diverged. This step has no functional behaviour to preserve.
- Check the components you did not customize — those you can usually take as-is.
- Re-check dark mode. Theme handling moved to the client in step 6, so this is a good moment to confirm both themes look right.
Validate Before Continuing
- [ ] Both light and dark mode render correctly
- [ ] Your own brand styling survived the merge
- [ ] Loading skeletons still match the shape of their pages — the fallbacks from step 3 were sized against the old design
- [ ] No layout shift when a boundary resolves
After Upgrading
Run all quality checks
pnpm installpnpm typecheckpnpm lint:fixpnpm format:fixpnpm buildTest core flows manually
Start the dev server and verify these flows work:
- [ ] Sign up / sign in
- [ ] Team creation and switching
- [ ] Inviting team members
- [ ] Billing 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/pricing | grep -i 'cache-control'curl -sI https://your-app.com/home | grep -i 'cache-control'Public pages should be shared-cacheable. Authenticated pages must be private, no-store.
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:
import { test, expect } from '@playwright/test';import { instant } from '@next/playwright';test('members page header appears instantly', async ({ page }) => { await page.goto('/home/my-team'); await instant(page, async () => { await page.click('a[href="/home/my-team/members"]'); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); }); // after instant() exits, streamed content arrives await expect(page.getByTestId('members-table')).toBeVisible();});Add one of these 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 instant = false exports — the step order is designed so the flag turns on cleanly. If you needed them for your own routes, treat them as a to-do list rather than a permanent state.
Switch to main for future updates
After completing the migration, pull future updates from main instead of v3:
git pull upstream mainRecommendations
- Use the Navigation Inspector. Next.js DevTools → Navigation Inspector → "Pause on navigations" freezes the page at its initial state so you can see exactly what your users see before data streams in. Pair it with the React DevTools Suspense panel to see which boundary covers what.
- 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. - Watch your server costs after Step 8.
allow-runtimetrades server invocations for perceived speed. Check your metrics after rollout.