Migrate to Next.js Supabase v3

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

v3 is a major upgrade that modernizes the entire stack:

  • Zod v4 — faster validation, smaller bundle, cleaner API
  • Base UI — headless primitives from the MUI team, replacing Radix
  • next-intl — first-class Next.js i18n with locale-prefixed routes
  • next-safe-action — type-safe server actions with built-in validation
  • Teams-only mode — ship team-based apps without personal accounts
  • Async dialogs — dialogs that won't close while operations are pending
  • Oxc — blazing-fast linting and formatting, replacing ESLint + Prettier
  • PNPM catalogs — one place to manage all dependency versions

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

Not Ready to Upgrade?

The v2 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 v2 branch:

git checkout v2

From now on, pull updates exclusively from v2:

git pull origin v2

We recommend upgrading to v3 when you can.

How the Upgrade Works

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

This means you can upgrade gradually: merge one PR, resolve any conflicts in your custom code, verify everything works, then move to the next. You don't have to do it all at once.

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

Merge Order

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

#TagWhat It Does
1v3-step/zodv4Updates Zod from v3 to v4 across all packages
2v3-step/baseuiSwaps Radix → Base UI primitives, react-i18next → next-intl
3v3-step/next-safe-actionReplaces enhanceAction with next-safe-action
4v3-step/locale-routesWraps all routes in [locale] segment
5v3-step/teams-onlyAdds feature flag for team-only apps
6v3-step/workspace-dropdownUnifies account/team switching UI
7v3-step/async-dialogsPrevents dialog dismissal during pending operations
8v3-step/oxcReplaces ESLint + Prettier with Oxc
9v3-step/remove-edge-csrfDrops CSRF middleware in favor of Server Actions
10v3-step/pnpm-catalogsCentralizes dependency versions

Before starting the migration

Please make sure your main branch is up to date with the branch v2. Also, make sure that the typecheck, lint and format commands run without errors.

If you're behind v2, please update it:

git pull origin v2

Now, make sure these commands run without errors:

pmpm typecheck
pnpm lint
pnpm format

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

Step-by-Step Process

For each step below, follow this process:

1. Merge the tag:

git merge <TAG>

2. Run the AI-assisted review using an AI coding agent (Claude Code, Cursor, etc.):

I'm upgrading Makerkit from v2 to v3. I just merged the tag `<TAG>`.
1. Read the V3_MIGRATION_GUIDE.md section for this step to understand
what changed.
2. Run `git diff HEAD~1` to see the upstream changes from this merge.
3. Identify all files where I have local customizations that conflict
or are incompatible with the upstream changes.
4. Enter plan mode. For each incompatibility, explain the conflict and
propose a resolution. My local changes must be preserved where
possible, while maintaining compatibility with upstream. Ask me to
clarify if you're unsure whether a local change should be kept or
updated to match upstream.
5. After I approve the plan, apply the fixes.
6. Run `pnpm install && pnpm typecheck` to verify.

3. Validate — check the section's checklist, then continue to the next step.

In general, always run pnpm typecheck. For best results, make sure this command returns no errors prior to starting the migration.


Table of Contents

  1. Zod v4
  2. Base UI + next-intl
  3. next-safe-action
  4. Locale Route Prefix
  5. Teams-Only Mode
  6. Workspace Dropdown
  7. Async Dialogs
  8. Oxc (ESLint/Prettier Replacement)
  9. Remove Edge CSRF
  10. PNPM Catalogs
  11. After Upgrading

1. Zod v4

git merge v3-step/zodv4

Then run the AI-assisted review with tag v3-step/zodv4.

Zod has been updated from v3 to v4.

Import Style

- import { z } from 'zod';
+ import * as z from 'zod';

This applies to every file that imports Zod.

Type Inference

- type MyType = z.infer<typeof MySchema>;
+ type MyType = z.output<typeof MySchema>;

Error Messages

z.string({
- required_error: 'Field is required',
- description: 'Some description',
+ error: 'Field is required',
})
  • required_errorerror
  • description in type constructors is removed

Refinement Functions

function validatePassword(password: string, ctx: z.RefinementCtx) {
if (password.length < 8) {
ctx.addIssue({ code: 'custom', message: 'Too short' });
}
- return true;
}

Remove return true from refinement callbacks.

What to Do

If you added custom Zod schemas:

  1. Find/replace import { z } from 'zod'import * as z from 'zod'
  2. Find/replace z.infer<z.output<
  3. Replace required_error with error in type constructors
  4. Remove description from type constructors
  5. Remove return true from refinement functions

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] No Zod import errors
  • [ ] No z.infer type errors
  • [ ] App builds and runs

2. Base UI + next-intl

git merge v3-step/baseui

Then run the AI-assisted review with tag v3-step/baseui.

Two major library swaps in one step.

UI: Radix → Base UI

The underlying primitives changed from Radix UI to Base UI (@base-ui/react). The @kit/ui/* component APIs mostly remain the same — this only affects you if you built custom components using Radix primitives directly.

If you did:

- import { Dialog as DialogPrimitive } from 'radix-ui';
+ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';

Data attributes changed:

- className="data-[state=open]:animate-in"
+ className="data-open:animate-in data-closed:animate-out"

Icons: @radix-ui/react-icons → lucide-react

- import { Cross2Icon } from '@radix-ui/react-icons';
+ import { XIcon } from 'lucide-react';

Replace all @radix-ui/react-icons imports with the equivalent from lucide-react.

i18n: react-i18next → next-intl

The entire i18n system changed.

Translation key syntax:

- <Trans i18nKey="namespace:key" />
+ <Trans i18nKey="namespace.key" />

Colon (:) becomes dot (.) in translation keys.

Server-side translations:

- import { getTranslation } from '~/lib/i18n/i18n.server';
- const { t } = await getTranslation(locale);
+ import { getTranslations } from 'next-intl/server';
+ const t = await getTranslations('namespace');

Messages Files

Message files moved to apps/web/i18n/messages/{locale}/.

Please migrate your existing messages to apps/web/i18n/messages/{locale}/.

Removed Components

These components were removed. If you used them, replace as noted:

RemovedReplacement
MultiStepFormBuild with Form + conditional step rendering
MobileNavigationMenuUse Sheet from @kit/ui/sheet
AuthenticityTokenRemoved — not needed with Server Actions

What to Do

If you added custom UI components:

  1. Replace @radix-ui/react-icons imports with lucide-react
  2. Update any direct Radix primitive imports to Base UI
  3. Update data-[state=open]data-open in custom styles
  4. Change all translation keys from colon to dot notation
  5. Replace react-i18next hooks with next-intl equivalents

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] No radix-ui or react-i18next import errors
  • [ ] Translation keys use dot notation
  • [ ] App builds and runs

3. next-safe-action

git merge v3-step/next-safe-action

Then run the AI-assisted review with tag v3-step/next-safe-action.

Server actions migrated from enhanceAction to next-safe-action.

The enhanceAction function is still available so your existing Server Actions will keep working just fine. Migrate when you have time.

Server Action Definition

'use server';
- import { enhanceAction } from '@kit/next/actions';
+ import { authActionClient } from '@kit/next/safe-action';
- export const myAction = enhanceAction(
- async (formData: FormData, user) => {
- const data = MySchema.parse(Object.fromEntries(formData));
- // ... logic
- },
- { schema: MySchema },
- );
+ export const myAction = authActionClient
+ .schema(MySchema)
+ .action(async ({ parsedInput: data, ctx: { user } }) => {
+ // data is already validated, user is in ctx
+ });

Available Clients

ClientImportUse Case
publicActionClient@kit/next/safe-actionNo auth required
authActionClient@kit/next/safe-actionRequires authenticated user
captchaActionClient@kit/next/safe-actionRequires CAPTCHA + auth

Client Components

- import { useFormStatus } from 'react-dom';
+ import { useAction } from 'next-safe-action/hooks';
function MyForm() {
- return (
- <form action={myAction}>
- <input type="hidden" name="field" value={value} />
- <SubmitButton />
- </form>
- );
+ const { execute, isPending } = useAction(myAction, {
+ onSuccess: ({ data }) => { /* ... */ },
+ onError: ({ error }) => { /* ... */ },
+ });
+
+ return (
+ <form onSubmit={(e) => { e.preventDefault(); execute({ field: value }); }}>
+ <button disabled={isPending}>Submit</button>
+ </form>
+ );
}

Key differences:

  • Pass objects to execute(), not FormData
  • isPending replaces useFormStatus
  • No hidden input fields needed
  • Error/success callbacks on the hook

What to Do

If you added custom server actions:

  1. Replace enhanceAction import with authActionClient (or publicActionClient)
  2. Rewrite action using .schema().action() chain
  3. Access user via ctx.user instead of second parameter
  4. Update client components to use useAction hook
  5. Remove hidden input fields and FormData patterns

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] No enhanceAction import errors (if you migrated custom actions)
  • [ ] Client components using useAction compile correctly
  • [ ] App builds and runs

4. Locale Route Prefix

git merge v3-step/locale-routes

Then run the AI-assisted review with tag v3-step/locale-routes.

All routes now live under a [locale] dynamic segment.

URL Structure

- /home
- /home/my-team
- /auth/sign-in
- /admin
+ /en/home
+ /en/home/my-team
+ /en/auth/sign-in
+ /en/admin

Directory Structure

apps/web/app/
+ ├── [locale]/
+ │ ├── (marketing)/
+ │ ├── admin/
+ │ ├── auth/
+ │ ├── home/
+ │ ├── layout.tsx ← i18n-aware layout (moved here)
+ │ └── not-found.tsx
├── layout.tsx ← minimal (just renders children)

Root Layout Simplified

// apps/web/app/layout.tsx — now just:
import '../styles/globals.css';
export default function RootLayout({ children }: React.PropsWithChildren) {
return children;
}

All providers, theme, and i18n setup moved to apps/web/app/[locale]/layout.tsx.

Middleware

middleware.ts is now proxy.ts with next-intl middleware integrated:

import createNextIntlMiddleware from 'next-intl/middleware';
import { routing } from '@kit/i18n/routing';
const handleI18nRouting = createNextIntlMiddleware(routing);
export default async function proxy(request: NextRequest) {
const response = handleI18nRouting(request);
// ... rest of middleware
}

TypeScript Paths

// apps/web/tsconfig.json
"paths": {
- "~/*": ["./app/*"]
+ "~/*": ["./app/[locale]/*", "./app/*"]
}

What to Do

If you added custom routes:

  1. Move your route folders into apps/web/app/[locale]/
  2. If you customized the root layout, move your changes to [locale]/layout.tsx

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] Custom routes are inside app/[locale]/
  • [ ] ~/* path aliases resolve correctly
  • [ ] App builds and runs, routes work with /en/ prefix

5. Teams-Only Mode

git merge v3-step/teams-only

Then run the AI-assisted review with tag v3-step/teams-only.

New feature flag for apps that only use team accounts (no personal accounts).

New Config

// apps/web/config/feature-flags.config.ts
enableTeamsOnly: import.meta.env.NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY === 'true',

New Env Variable

NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY=false

Set to true if your app should skip personal accounts entirely.

What to Do

No action required unless you want to enable teams-only mode. Add the env variable and set it to true.

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] App builds and runs

6. Workspace Dropdown

git merge v3-step/workspace-dropdown

Then run the AI-assisted review with tag v3-step/workspace-dropdown.

Account/team switching moved from sidebar navigation to a unified dropdown component.

What Changed

  • New WorkspaceDropdown component handles both personal and team switching
  • Billing and member management page layouts refactored
  • Notifications popover integrated

What to Do

If you customized the sidebar account selector, migrate to the new WorkspaceDropdown component. If you only used the default navigation, no changes needed.

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] Workspace switching works (personal + team accounts)
  • [ ] App builds and runs

7. Async Dialogs

git merge v3-step/async-dialogs

Then run the AI-assisted review with tag v3-step/async-dialogs.

New useAsyncDialog hook prevents dialogs from closing during pending operations (form submissions, API calls).

Usage

import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
function MyDialog({ open, onOpenChange }) {
const { dialogProps, isPending, setIsPending } = useAsyncDialog({
open,
onOpenChange,
});
const { execute } = useAction(myAction, {
onExecute: () => setIsPending(true),
onSettled: () => setIsPending(false),
});
return (
<Dialog {...dialogProps}>
{/* Dialog blocks ESC/backdrop click while isPending */}
<Button disabled={isPending}>Submit</Button>
</Dialog>
);
}

What to Do

If you built custom dialogs with forms, adopt useAsyncDialog to prevent accidental closure during submissions. Existing dialogs still work without it — this is an improvement, not a requirement.

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] App builds and runs

8. Oxc

git merge v3-step/oxc

Then run the AI-assisted review with tag v3-step/oxc.

ESLint + Prettier replaced with Oxc (oxlint + oxfmt).

Commands

- pnpm lint # ESLint
- pnpm format # Prettier
+ pnpm lint:fix # oxlint
+ pnpm format:fix # oxfmt

Config Files

- eslint.config.mjs (removed from all packages)
- .prettierignore (removed)
- .prettierrc (removed)
+ .oxlintrc.json (root)
+ .oxfmtrc.jsonc (root)

What to Do

If you added custom ESLint rules:

  1. Translate them to .oxlintrc.json format
  2. Delete any eslint.config.mjs files you added
  3. Delete Prettier config files
  4. Run pnpm lint:fix && pnpm format:fix to reformat

Validate Before Continuing

pnpm install && pnpm typecheck && pnpm lint:fix && pnpm format:fix
  • [ ] No ESLint/Prettier config files remain
  • [ ] pnpm lint:fix runs without errors
  • [ ] App builds and runs

9. Remove Edge CSRF

git merge v3-step/remove-edge-csrf

Then run the AI-assisted review with tag v3-step/remove-edge-csrf.

The @edge-csrf/nextjs package and useCsrfToken hook are removed. Next.js + Server Actions handle CSRF protection natively.

What Changed

- import { useCsrfToken } from '@kit/shared/hooks/use-csrf-token';
// Removed — no replacement needed

CSRF middleware removed from proxy.ts. Server Actions are inherently protected by Next.js.

What to Do

If you used useCsrfToken() in custom components, remove those calls. No replacement is needed — Server Actions handle CSRF automatically.

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] No useCsrfToken or @edge-csrf import errors
  • [ ] App builds and runs

10. PNPM Catalogs

git merge v3-step/pnpm-catalogs

Then run the AI-assisted review with tag v3-step/pnpm-catalogs.

Dependency versions are now centralized in pnpm-workspace.yaml using PNPM catalogs.

How It Works

# pnpm-workspace.yaml
catalog:
react: 19.2.4
next: 16.1.6
zod: 4.3.6
# ... all shared versions here
// Individual package.json files now reference the catalog:
{
"dependencies": {
"react": "catalog:",
"next": "catalog:"
}
}

What to Do

If you added custom dependencies to individual packages:

  • Shared deps (used by multiple packages): Add to catalog: in pnpm-workspace.yaml, then reference as "catalog:" in package.json
  • Package-specific deps: Can still use direct version strings

Validate Before Continuing

pnpm install && pnpm typecheck
  • [ ] pnpm install resolves all catalog references
  • [ ] App builds and runs

After Upgrading

Run these commands after completing all steps:

pnpm install
pnpm typecheck
pnpm lint:fix
pnpm format:fix