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
pnpm add @synqid/nextjsandpnpm remove next-auth.auth.ts→lib/synq.ts(onecreateSynqcall).app/api/auth/[...nextauth]/route.ts→app/api/auth/[...synq]/route.ts.<SessionProvider>→<SynqProvider>. Seed it server-side.useSession()→useUser().getServerSession()→verifySession().- 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/nextjs | Notes |
|---|---|---|
auth.ts (NextAuth config) | lib/synq.ts (createSynq) | Single configuration module. |
handlers.GET / handlers.POST | handlers(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. |
SessionProvider | SynqProvider | Seed 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 store | See “Adapters” below. |
Callbacks (signIn, jwt, session) | Custom claims + requireAccess | See “Callbacks” below. |
events.signIn / events.signOut | Webhooks | See Webhooks. |
NEXTAUTH_URL | APP_URL | |
NEXTAUTH_SECRET | SESSION_SECRET | Same security requirements — 32+ chars. |
Step 1 — install
pnpm remove next-auth @auth/prisma-adapter # or whichever adapter you used
pnpm add @synqid/nextjsIf you have @auth/core listed explicitly, remove it too — it was a
transitive dep of next-auth.
Step 2 — auth.ts → lib/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/signOutexports — 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.tsURLs 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>rankeepAliverefreshes 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.userAfter:
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 | nullStep 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 nullStep 8 — signIn() / signOut() → plain links
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 app →
requireAccess(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 event | Synq webhook |
|---|---|
signIn | consent.granted (only fires on first sign-in to an App; for every-sign-in, use access logs) |
signOut | (no equivalent — local-only) |
createUser | member.added |
updateUser | member.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.ts → app/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 forverifySession()). - 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.