Migrating from Clerk
Clerk gave you a hosted login UI, a drop-in <UserButton />, a
useUser() hook, and a Protect middleware. Synq gives you the same
shape — hosted login (themed by Brand), a server-first session check,
a useUser() hook — but with a different center of gravity. Clerk
wants to own your UI components; Synq wants to own your OIDC and get
out of your way.
If you are migrating because you outgrew Clerk’s component API, this guide is for you. If you are migrating because Clerk’s bill grew faster than your headcount, this guide is also for you.
Mental model differences
Read this section first. Half the surprises in a Clerk → Synq move come from one of these.
Hosted UI scope
Clerk ships <SignIn />, <SignUp />, <UserButton />,
<OrganizationSwitcher />, <UserProfile /> — full React components
you mount in your app. They render Clerk’s UI inside your DOM.
Synq’s hosted UI lives at synq.id (or your Brand’s custom domain).
You redirect to it, the user signs in, you receive a callback,
done. Synq does not render auth UI inside your app. That is a deliberate
trade-off: less control over the surface, much less SDK weight and
zero “Clerk components don’t match our design system” tickets.
The components you used to drop in become two patterns instead:
<UserButton />→ roll your own withuseUser()+ your design system. Sample at the end.<SignIn />/<SignUp />→ link to/api/auth/login. No client-side form, no custom-fields config, no hidden iframe.
Session refresh cadence
Clerk refreshes session tokens roughly every minute. That gives you
near-real-time revocation but it means every tab is constantly
chattering with clerk.com. The component tree assumes this rhythm
and re-renders accordingly.
Synq uses standard OIDC refresh tokens (30-day sliding) and lets the access token live for an hour. Revocation happens at the next refresh, or immediately if you call your backend with a fresh access token check. The trade-off: less chatter, lower per-request latency, slightly slower revocation. If you require sub-minute revocation, read Sessions and tokens before cutover — there are knobs.
Org model
Clerk has Organizations with Members and Roles. The roles are a small
fixed set (admin, basic_member, custom) and they live inside
Clerk’s metadata.
Synq has Organizations and Brands. The Org maps closely. The Brand is the extra layer — it’s the trust surface (logo, login screen, BYO credentials). One Org → one or more Brands → one or more Apps per Brand.
The 95% case: one Clerk Org = one Synq Org + one Synq Brand. Brands get interesting when you have multiple consumer products or a white-label setup. Don’t sweat it on day one; you can split later.
User identity
Clerk: a user has a primary email + secondary emails + a username + optional phone numbers + linked social accounts. Profile fields you configure in the dashboard.
Synq: a user is an opaque id + a current display name + linked provider identities. Email is attached to the provider identity, not the user, because a user with three linked providers can have three different emails. See Identity model.
| Clerk | Synq |
|---|---|
user.id | user.sub (a.k.a. user.id) |
user.primaryEmailAddress.emailAddress | id_token.email at sign-in; not persisted on the Synq user record |
user.username | (not modeled — pick a slug in your product) |
user.firstName / user.lastName | user.given_name / user.family_name from the profile scope |
user.imageUrl | user.picture from the profile scope |
user.publicMetadata | Custom claims, configured per-Brand |
user.privateMetadata | Stored in your database, not Synq |
user.unsafeMetadata | Same — keep it in your product DB |
Synq’s deliberate stance: your product database holds your product data. Synq holds identity, not profile.
Step 1 — install
pnpm remove @clerk/nextjs @clerk/themes
pnpm add @synqid/nextjsIf you used @clerk/backend server-side, remove it too — verifySession()
covers what you used it for.
Step 2 — replace <ClerkProvider> with <SynqProvider>
Before — app/layout.tsx:
import { ClerkProvider } from '@clerk/nextjs'
export default function RootLayout({ children }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
)
}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 differences:
<ClerkProvider>fetches the user from Clerk on mount.<SynqProvider>is seeded from the server — you readgetUser()in the layout and pass it in. No network call from the provider.- The layout becomes
async. If you were on a client-componentRootLayout, flip it to a server component first.
You still need lib/synq.ts:
// src/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!,
}
export const { verifySession, getUser, requireGuest, requireAccess, auth } =
createSynq(synqConfig)Env var mapping:
| Clerk | Synq |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | (none — Synq doesn’t ship a publishable key) |
CLERK_SECRET_KEY | SYNQ_CLIENT_SECRET |
| (n/a — Clerk derives) | SYNQ_CLIENT_ID |
NEXT_PUBLIC_CLERK_SIGN_IN_URL | (none — link to /api/auth/login) |
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL | Pass ?returnTo=/dashboard on the login link |
| (n/a) | SESSION_SECRET (32+ chars; openssl rand -base64 32) |
| (n/a) | APP_URL |
Step 3 — add the route handler
Brand new file:
// src/app/api/auth/[...synq]/route.ts
import { handlers } from '@synqid/nextjs/handlers'
import { synqConfig } from '@/lib/synq'
export const { GET, POST } = handlers(synqConfig)Clerk uses app/[[...sign-in]]/page.tsx and a middleware to wire
auth routes. Synq uses a catch-all API route. You can delete the
Clerk catch-all page.
Step 4 — replace clerkMiddleware() with synqProxy()
Before — middleware.ts:
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublic = createRouteMatcher(['/', '/about', '/api/health'])
export default clerkMiddleware((auth, req) => {
if (!isPublic(req)) auth().protect()
})
export const config = {
matcher: ['/((?!_next|favicon.ico).*)'],
}After — src/proxy.ts (Next 16 renamed middleware to proxy):
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).*)'],
}publicPaths accepts plain paths or glob patterns. /api/auth/* is
implicit — you cannot accidentally gate yourself out of the auth
endpoints.
Step 5 — replace auth() / currentUser() server reads
Before:
import { auth, currentUser } from '@clerk/nextjs/server'
// In a server component:
const { userId } = auth()
const user = await currentUser()After:
import { verifySession } from '@/lib/synq'
const { user } = await verifySession()
// user.sub === Clerk's userId equivalent
// user.email / user.name / user.picture from claimsOr use the alias:
import { auth } from '@/lib/synq'
const { user } = await auth()auth() is a direct alias for verifySession() exactly so the
muscle memory keeps working.
The soft variant — for headers that show “Sign in” when logged out and “Hi $name” otherwise:
import { getUser } from '@/lib/synq'
const user = await getUser()
if (!user) return <SignInButton />
return <Greeting name={user.name} />Step 6 — replace useUser() / useSession() client reads
Good news: the hook name and shape are the same.
Before:
'use client'
import { useUser } from '@clerk/nextjs'
export function Header() {
const { isSignedIn, user, isLoaded } = useUser()
if (!isLoaded) return null
return isSignedIn ? <Avatar src={user.imageUrl} /> : <SignInLink />
}After:
'use client'
import { useUser } from '@synqid/nextjs/client'
export function Header() {
const { isSignedIn, user } = useUser()
// No isLoaded — the user was seeded server-side, so we already know.
return isSignedIn ? <Avatar src={user!.picture} /> : <SignInLink />
}The isLoaded flag is gone because the provider doesn’t fetch — the
user is known on first paint. That removes a class of “loading
skeleton flashed for 50ms” bugs.
Step 7 — replace <Protect> with requireAccess
Clerk’s <Protect> component does both authn and authz inside a JSX
tree. Synq does authz exclusively in server code via
requireAccess(predicate).
Before:
import { Protect } from '@clerk/nextjs'
<Protect permission="org:billing:manage" fallback={<NoAccess />}>
<BillingPage />
</Protect>After:
// app/billing/page.tsx
import { requireAccess } from '@/lib/synq'
import { yourProductSdk } from '@/lib/sdk'
export default async function BillingPage() {
const { user } = await requireAccess(async () => {
const perms = await yourProductSdk.permissions.me()
return perms.includes('BillingManage')
})
return <BillingPageInner user={user} />
}The requireAccess predicate is the only place permission logic
lives. Synq has no opinion. This pushes you toward modeling
permissions in your product database where you can change them
without a re-login, which is the right move at any non-trivial scale.
If you want a Clerk-style component for ergonomic JSX gating, write a 10-line wrapper:
// components/Gate.tsx (server component)
import { getUser } from '@/lib/synq'
export async function Gate({
when, fallback, children,
}: { when: (u: SynqUser) => Promise<boolean>; fallback?: ReactNode; children: ReactNode }) {
const user = await getUser()
if (!user || !(await when(user))) return <>{fallback ?? null}</>
return <>{children}</>
}Step 8 — replace <UserButton /> (roll your own)
Clerk’s <UserButton /> is a logo + dropdown + manage account + sign
out, all in one. Synq does not ship this component. You write ~30
lines and it does exactly what your design system wants.
'use client'
import { useUser } from '@synqid/nextjs/client'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/DropdownMenu'
export function UserButton() {
const { user, isSignedIn } = useUser()
if (!isSignedIn) return <a href="/api/auth/login">Sign in</a>
return (
<DropdownMenu>
<DropdownMenuTrigger>
<img
src={user!.picture ?? '/default-avatar.png'}
alt={user!.name ?? 'You'}
className="h-8 w-8 rounded-full"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled>{user!.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/account">Account</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/api/auth/logout">Sign out</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}This is most of what <UserButton /> did. The win is total: your
button, your design system, your hover state, your dropdown library.
Step 9 — webhooks
Clerk publishes events via Svix. Synq publishes its own. Same idea, different event names.
| Clerk | Synq |
|---|---|
user.created | member.added (when the user joins your Org) |
user.updated | member.updated |
user.deleted | member.removed |
session.created | consent.granted (first sign-in to an App in your Org) |
session.removed | (no equivalent — sessions are local; if you need cross-device sign-out events, ping us) |
organization.created | (no equivalent — Synq Orgs are developer entities, not product tenants) |
organizationMembership.created | member.added |
organizationMembership.deleted | member.removed |
See Webhooks for HMAC verification, retries, and dedup semantics.
Clerk’s webhooks shipped with Svix signature headers; Synq uses
Synq-Signature (raw-body HMAC-SHA256, hex). The verification pattern
is the same shape, the secret comes back to you once on subscription.
Step 10 — switch JWKS at your backend
If your backend verifies Clerk session tokens (JWT_KEY /
api.clerk.com/v1/jwks), switch to Synq:
https://api.synq.id/.well-known/jwks.jsonimport { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://api.synq.id/.well-known/jwks.json'),
)
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://api.synq.id',
audience: 'acme-marketplace', // Brand slug, NOT client_id
})
return payload
}Gradual rollout
You can run both providers side-by-side behind a feature flag. The trick: switch which provider the layout uses based on a cookie or header.
// app/layout.tsx
import { cookies } from 'next/headers'
import { getUser as getSynqUser } from '@/lib/synq'
import { SynqProvider } from '@synqid/nextjs/client'
import { ClerkProvider, currentUser as getClerkUser } from '@clerk/nextjs/server'
export default async function RootLayout({ children }) {
const useSynq = (await cookies()).get('use_synq')?.value === '1'
if (useSynq) {
const user = await getSynqUser()
return (
<html><body>
<SynqProvider user={user}>{children}</SynqProvider>
</body></html>
)
}
const user = await getClerkUser()
return (
<ClerkProvider>
<html><body>{children}</body></html>
</ClerkProvider>
)
}Backend dual-verify is identical to the Auth0 guide’s dual-issuer guard; read that section if you need zero-downtime.
Gotchas
- Clerk Components inside your design system. If you used Clerk’s
components heavily (e.g.
<OrganizationProfile />), expect to rebuild a couple of pages by hand. The flip side: your design system finally gets to own them. - Custom session claims. Clerk’s “JWT Templates” become Synq custom claims, configured per-Brand. Same idea, different UI.
publicMetadataon the user. Move it to your product DB, key byuser.sub. The auth token shouldn’t carry product state.signOut()from a button. Clerk gave you a hook. Synq gives you a link:<a href="/api/auth/logout">Sign out</a>. Less JavaScript shipped to the client.- Multiple sessions in one tab. Clerk’s multi-session feature has no direct Synq analog. If you need it, you can simulate it by signing in to multiple Apps under the same Brand and switching between them — but it’s a different shape. Talk to us if this is load-bearing.
- Magic link. Synq supports it natively as a Brand connection. No additional code.
- Password sign-in. Synq does not support user passwords. Users authenticate via providers (Google, Apple, Microsoft, magic link, Discord, etc.). If your Clerk users use email+password, plan a password-reset notice or a magic-link-on-first-Synq-login flow.
What stays the same
- The hook name (
useUser()). - The server check name (
auth()works as an alias forverifySession()). - Most of the URL shape —
/api/auth/login,/api/auth/logout,/api/auth/callback,/api/auth/session. - The mental model of “authn at the edge, authz at the page”.
- Webhook-based event handling.
Open questions / drop us a note
Clerk users tend to lean on the React component layer harder than Auth0 users lean on Rules. If a specific Clerk component is load-bearing in your product and you can’t see the Synq path, write to support@synq.id. We are happy to design the equivalent with you.
See also: Sessions and tokens, Identity model, Webhooks, Organizations and brands.