Skip to Content

Migrating from NextAuth.js (Auth.js v5)

This is the smallest of our migration guides because the move is the smallest. @synqid/nextjs and NextAuth share the same architectural DNA — a catch-all route handler, an auth() server check, a useSession() hook, a middleware. If your NextAuth setup is healthy, you can do this migration in an afternoon.

The interesting differences are at the edges: providers move from your code to the Synq dashboard, adapters go away entirely, and signIn() becomes a plain link.

TL;DR — the six moves

  1. pnpm add @synqid/nextjs and pnpm remove next-auth.
  2. auth.tslib/synq.ts (one createSynq call).
  3. app/api/auth/[...nextauth]/route.tsapp/api/auth/[...synq]/route.ts.
  4. <SessionProvider><SynqProvider>. Seed it server-side.
  5. useSession()useUser(). getServerSession()verifySession().
  6. Move providers + adapter config out of code: providers become Brand connections in the Synq dashboard; the adapter goes away (Synq owns the session store).

Concept mapping — side-by-side

NextAuth v5@synqid/nextjsNotes
auth.ts (NextAuth config)lib/synq.ts (createSynq)Single configuration module.
handlers.GET / handlers.POSThandlers(synqConfig)Same shape.
auth()auth() (alias) or verifySession()auth is an alias for muscle-memory parity.
getServerSession() (v4)verifySession()v5 dropped this in favor of auth(). So has Synq.
useSession()useUser() / useSession()Both exported; useSession() returns the session envelope.
SessionProviderSynqProviderSeed from server.
signIn()<a href="/api/auth/login">No client hook — just a link.
signOut()<a href="/api/auth/logout">Same.
withAuth() (v4) / auth middleware (v5)synqProxy()Same idea.
Providers (providers: [...] in code)Brand connections (in dashboard)See “Providers” below.
Adapter (Prisma, Drizzle, Mongo)None — Synq owns the session storeSee “Adapters” below.
Callbacks (signIn, jwt, session)Custom claims + requireAccessSee “Callbacks” below.
events.signIn / events.signOutWebhooksSee Webhooks.
NEXTAUTH_URLAPP_URL
NEXTAUTH_SECRETSESSION_SECRETSame security requirements — 32+ chars.

Step 1 — install

pnpm remove next-auth @auth/prisma-adapter # or whichever adapter you used pnpm add @synqid/nextjs

If you have @auth/core listed explicitly, remove it too — it was a transitive dep of next-auth.

Step 2 — auth.tslib/synq.ts

Before — auth.ts:

import NextAuth from 'next-auth' import Google from 'next-auth/providers/google' import GitHub from 'next-auth/providers/github' import { PrismaAdapter } from '@auth/prisma-adapter' import { prisma } from '@/lib/prisma' export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [Google, GitHub], session: { strategy: 'jwt' }, callbacks: { async jwt({ token, user }) { if (user) token.role = user.role return token }, async session({ session, token }) { if (token.role) session.user.role = token.role as string return session }, }, })

After — lib/synq.ts:

import { createSynq } from '@synqid/nextjs/server' export const synqConfig = { clientId: process.env.SYNQ_CLIENT_ID!, clientSecret: process.env.SYNQ_CLIENT_SECRET!, appUrl: process.env.APP_URL!, sessionSecret: process.env.SESSION_SECRET!, scopes: ['openid', 'profile', 'email'], } export const { verifySession, getUser, requireGuest, requireAccess, auth } = createSynq(synqConfig)

Then app/api/auth/[...synq]/route.ts:

import { handlers } from '@synqid/nextjs/handlers' import { synqConfig } from '@/lib/synq' export const { GET, POST } = handlers(synqConfig)

Notice everything that left:

  • No providers — they’re configured per-Brand in the Synq dashboard. See “Providers” below.
  • No adapter — Synq owns the session store.
  • No callbacks — see “Callbacks” below.
  • No signIn / signOut exports — you use plain <a> links.

Step 3 — the route handler

The matcher segment name changes from [...nextauth] to [...synq]. Move the file accordingly:

- src/app/api/auth/[...nextauth]/route.ts + src/app/api/auth/[...synq]/route.ts

URLs that survive unchanged:

  • /api/auth/callback (yes, same path — Synq’s callback lives at the same URL NextAuth’s did)
  • /api/auth/session (NextAuth: same path)
  • /api/auth/signout (NextAuth) → /api/auth/logout (Synq) — this one changes
  • /api/auth/signin (NextAuth) → /api/auth/login (Synq) — this one changes

If you have <a href="/api/auth/signin"> links scattered through your templates, grep and replace.

Step 4 — the provider

Before — app/layout.tsx:

import { SessionProvider } from 'next-auth/react' export default function RootLayout({ children }) { return ( <html><body> <SessionProvider>{children}</SessionProvider> </body></html> ) }

After:

import { getUser } from '@/lib/synq' import { SynqProvider } from '@synqid/nextjs/client' export default async function RootLayout({ children }) { const user = await getUser() return ( <html lang="en"> <body> <SynqProvider user={user}>{children}</SynqProvider> </body> </html> ) }

Two upgrades come for free:

  • The layout becomes async and pre-loads the user. No status === 'loading' flash on hydration.
  • <SynqProvider> doesn’t poll. NextAuth’s <SessionProvider> ran keepAlive refreshes by default; Synq lets the standard OIDC refresh-token cadence handle it.

Step 5 — proxy (was middleware)

Before — middleware.ts:

export { auth as middleware } from '@/auth' export const config = { matcher: ['/dashboard/:path*', '/account/:path*'], }

After — src/proxy.ts:

import { synqProxy } from '@synqid/nextjs/proxy' export const proxy = synqProxy({ publicPaths: ['/', '/about', '/api/health'], }) export const config = { matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'], }

The semantic shift: NextAuth’s middleware can do both authn gating and authz (via callbacks). Synq’s proxy does only authn gating (cookie present? if no, redirect). Authz lives in the page, in requireAccess. That separation is what removes the gnarly-callback class of bugs.

Next 16 moved middleware to proxy; the matcher syntax is unchanged.

Step 6 — auth() and getServerSession()

Before:

import { auth } from '@/auth' const session = await auth() if (!session?.user) return redirect('/login') const user = session.user

After:

import { verifySession } from '@/lib/synq' // or: import { auth } from '@/lib/synq' if you prefer the old name const { user } = await verifySession() // Redirect is automatic if there's no session.

The soft variant (used to be auth()null):

import { getUser } from '@/lib/synq' const user = await getUser() // SynqUser | null

Step 7 — useSession()useUser()

Before:

'use client' import { useSession, signIn } from 'next-auth/react' export function Header() { const { data: session, status } = useSession() if (status === 'loading') return null if (!session) return <button onClick={() => signIn()}>Sign in</button> return <Avatar src={session.user?.image} /> }

After:

'use client' import { useUser } from '@synqid/nextjs/client' export function Header() { const { isSignedIn, user } = useUser() if (!isSignedIn) return <a href="/api/auth/login">Sign in</a> return <Avatar src={user!.picture} /> }

Wins:

  • No status === 'loading' — the provider was seeded server-side.
  • signIn() is a plain link. No client JS, no MouseEvent, no preventDefault.

If you really want the session envelope:

import { useSession } from '@synqid/nextjs/client' const { session } = useSession() // session.user, or null

This is the most visible change. NextAuth gave you a function. Synq gives you a link.

Before:

<button onClick={() => signIn('google', { callbackUrl: '/dashboard' })}> Continue with Google </button> <button onClick={() => signOut({ callbackUrl: '/' })}>Sign out</button>

After:

<a href="/api/auth/login?provider=google&returnTo=/dashboard"> Continue with Google </a> <a href="/api/auth/logout?returnTo=/">Sign out</a>

The win is that nothing in the client bundle does sign-in. The browser navigates, Synq handles it, you come back. Less JavaScript, fewer race conditions, simpler error states (the browser shows a network error like for any other navigation).

Providers — they move to the dashboard

NextAuth has you configure providers in code:

providers: [ Google({ clientId, clientSecret }), GitHub({ clientId, clientSecret }), Apple({ clientId, clientSecret, /* signing key, etc. */ }), ],

Synq has you configure them per-Brand in the dashboard, under Brand → Connections. Each connection is a provider with BYO credentials (your own Google client_id + secret) or Synq’s defaults. See BYO credentials.

Two upsides:

  • You can rotate provider secrets without a deploy.
  • You can add a new provider (e.g. enable Discord for a campaign) without shipping code.

One downside:

  • Your provider list is no longer reviewable in git. If you care, the Brand admin API can dump current connections and you can store the output in a config repo.

Adapters — they go away

The NextAuth adapter persisted users / sessions / accounts to your database. Synq does not need one because Synq holds users / sessions / accounts on its side.

Your product database should still hold your product data, but it no longer needs users, accounts, sessions, verification_tokens tables for the auth library. You can drop those tables (or rename the users table to app_users and key by Synq sub).

If you needed the adapter for session storage (you wanted DB-backed sessions for cross-device revocation), see Sessions and tokens for Synq’s revocation model. DB-backed sessions are not currently a Synq feature; if you need sub-minute global sign-out, talk to us.

Callbacks — gone, replaced by two patterns

NextAuth’s callbacks.jwt / callbacks.session were the seam for “add role to the token” and similar. Synq splits this in two:

  • Adding claims to issued tokens → Custom claims, configured per-Brand in the Synq dashboard. The Brand declares a lookup; Synq populates the claim on every token mint.
  • Authz decisions in your apprequireAccess(predicate) in the page / route / action.

Before:

callbacks: { async session({ session, token }) { session.user.role = await fetchRoleFromDb(token.sub!) return session }, }, // then in a page: const session = await auth() if (session?.user?.role !== 'admin') redirect('/')

After:

// In a server component: import { requireAccess } from '@/lib/synq' const { user } = await requireAccess(async ({ user }) => { const role = await fetchRoleFromDb(user.sub) return role === 'admin' })

Pulling the role out of the token has a benefit beyond cleanliness: role changes take effect on the next request, not the next sign-in.

The signIn callback (block sign-in conditionally) has no direct Synq equivalent — Synq does not invoke product code mid-flow. The equivalent is a requireAccess predicate at the first authenticated page (block by emitting redirect('/blocked')), or a webhook on member.added that reverses the join.

Events → webhooks

NextAuth eventSynq webhook
signInconsent.granted (only fires on first sign-in to an App; for every-sign-in, use access logs)
signOut(no equivalent — local-only)
createUsermember.added
updateUsermember.updated
linkAccount(no equivalent today — near-term roadmap)
session(no — too noisy)

See Webhooks. The HMAC verification is a straightforward port from NextAuth’s webhook handlers if you had any.

CredentialsProvider — not supported

NextAuth’s CredentialsProvider lets you accept a username + password in your own code and validate against your own DB. Synq does not support password auth client-side. Users sign in via providers (Google, Apple, Microsoft, Discord, magic link).

If you have a CredentialsProvider in production, your options are:

  • Send a password-reset notice. Tell users to sign in via the social provider matching their email. Simple if your users have a Google / Apple email.
  • Magic link. Synq supports magic link as a Brand connection. Enable it and your password-using users can sign in with a one-click email.
  • Provider-mapping import. If your users have a registered Google email, you can pre-create a Synq user and pre-link the Google identity, so they land in a session that already knows who they are. Talk to us about Directory imports.

Edge runtime

NextAuth supports edge. So does Synq. The proxy is edge-compatible by default; route handlers and server components are not (they read the encrypted cookie, which uses Node’s crypto). The default mapping — proxy at edge, handlers + components at Node — is exactly what NextAuth recommended too.

A complete diff — login route

app/api/auth/[...nextauth]/route.tsapp/api/auth/[...synq]/route.ts:

// BEFORE import { handlers } from '@/auth' export const { GET, POST } = handlers
// AFTER import { handlers } from '@synqid/nextjs/handlers' import { synqConfig } from '@/lib/synq' export const { GET, POST } = handlers(synqConfig)

Yeah, that’s the file. The rest of the migration is mostly renames.

What stays the same

  • The shape of auth() (alias for verifySession()).
  • The pattern: catch-all route + provider + server-side check.
  • Edge-runtime support at the middleware layer (proxy.ts).
  • JWT-based access tokens, verified locally against JWKS.
  • Cookie-based session in the browser.

Open questions / drop us a note

NextAuth v5’s surface area is wide (RegisteredCallbacks, PageMaps, custom logger config). If we missed a hook you depend on, write to support@synq.id and we will document the Synq equivalent or, if there isn’t one, flag it on the roadmap.

See also: Sessions and tokens, BYO credentials, Webhooks.

Last updated on