Skip to Content
SDKs@synqid/nextjs

@synqid/nextjs

The official Synq SDK for Next.js 16+. Drop-in OIDC integration — an auth-aware proxy, a typed Data Access Layer, a single catch-all OIDC route handler, and a React provider + hooks. Zero proprietary auth code in your app; the SDK speaks the standard OIDC spec end to end.

AI agent? Fetch /llms.txt for the canonical doc index and ready-to-paste Next.js snippets. Critical env vars are server-side: SYNQ_CLIENT_ID, SYNQ_CLIENT_SECRET, SYNQ_ISSUER, SYNQ_REDIRECT_URI. Do not expose them with the NEXT_PUBLIC_* prefix.

If you have shipped against NextAuth, Clerk, or WorkOS, this should feel immediately familiar. The architecture mirrors the canonical Next.js auth guide  exactly: proxy.ts does optimistic cookie-presence gating at the edge, your server components do authoritative verification via the DAL, and your client components read user state from a small React context.

Install

pnpm add @synqid/nextjs

Requires next >= 16 and react >= 19.

Configure once

Create a single src/lib/synq.ts that builds the SDK and exports the primitives. Everything downstream — pages, layouts, route handlers, the proxy — imports from here.

// 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!, // openssl rand -base64 32 // Optional overrides — sensible defaults are listed in `SynqConfig`. // issuer: 'https://api.synq.id', // scopes: ['openid', 'profile', 'email'], } export const { verifySession, getUser, requireGuest, requireAccess, auth } = createSynq(synqConfig)

SESSION_SECRET must be at least 32 characters. Generate one with openssl rand -base64 32 and store it in .env. Never commit it.

Three files to wire it up

1. The route handler

A single catch-all route mounted at /api/auth/[...synq] handles the whole OIDC flow — login, callback, logout, session.

// src/app/api/auth/[...synq]/route.ts import { handlers } from '@synqid/nextjs/handlers' import { synqConfig } from '@/lib/synq' export const { GET, POST } = handlers(synqConfig)

That’s it. The endpoints are:

RouteMethodWhat it does
/api/auth/loginGETStart auth-code + PKCE, redirect to Synq
/api/auth/callbackGETExchange code for tokens, set session cookie
/api/auth/logoutGET, POSTClear session, RP-initiated logout
/api/auth/sessionGETJSON { user } | null for client polling

2. The proxy

proxy.ts is the optimistic edge gate. Cookie present → continue. Cookie absent → 307 to /api/auth/login. No network, no DB, no decrypt — that’s by design (Next docs ).

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

publicPaths is appended to the SDK’s built-in public list (/api/auth/*), so the OIDC routes are always reachable.

Need to chain another middleware (i18n, A/B, preview)? Pass a chain: and it’ll run after the auth check:

import { synqProxy } from '@synqid/nextjs/proxy' import { createI18nMiddleware } from 'next-international/middleware' const i18n = createI18nMiddleware({ locales: ['en', 'es'], defaultLocale: 'en' }) export const proxy = synqProxy({ chain: (req) => i18n(req), })

3. The provider

Mount <SynqProvider> once near the root. Seed it with the server-side user so the first render already knows who you are — no flash of unauthenticated UI.

// src/app/layout.tsx 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> ) }

Done. You can now use verifySession in pages and useUser in client components.

Use it

In a server component (the most common case)

// src/app/dashboard/page.tsx import { verifySession } from '@/lib/synq' export default async function Dashboard() { const { user } = await verifySession() return <h1>Welcome, {user.name}</h1> }

verifySession() redirects to /api/auth/login if there’s no session. It’s cached per render (React’s cache()), so calling it three times across your layout + page + sub-component still only hits the cookie once.

In a client component

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

The hook reads from the SynqProvider context — no network, no hydration mismatch, no flash of guest UI.

On the login page (kick signed-in users away)

// src/app/login/page.tsx — the local login page, IF you have one import { requireGuest } from '@/lib/synq' export default async function LoginPage() { await requireGuest({ redirectTo: '/dashboard' }) return <YourLoginUI /> }

In practice you usually link directly to /api/auth/login and skip having a local /login page at all. Synq’s hosted login screen takes over from there.

Authorization with requireAccess

This is where Synq deliberately gets out of your way. Synq has no opinion about what “permission” means in your product. Pass a predicate; if it returns false, the user is redirected.

import { requireAccess } from '@/lib/synq' // Gating by a permission enum from your product: const { user } = await requireAccess(async () => { const perms = await yourProductSdk.permissions.me() return perms.includes('CampaignEdit') }) // Gating by row-level ownership: const { user } = await requireAccess( ({ user }) => campaign.ownerId === user.sub ) // Gating by a feature flag: const { user } = await requireAccess( ({ user }) => flags.isEnabled('beta-dashboard', user) )

The predicate is the only place authorization logic lives. Whether it’s a simple permission check, a remote policy decision, or a local feature flag — Synq just calls the function, reads the boolean, and redirects on false. Single seam.

In a Server Action or Route Handler

Treat them like any other entry point — verify at the top.

'use server' import { verifySession } from '@/lib/synq' export async function updateProfile(formData: FormData) { const { user } = await verifySession() // …mutate, scoped to user.sub }
// src/app/api/whatever/route.ts import { verifySession } from '@/lib/synq' export async function POST(request: Request) { const { user } = await verifySession() // … }

API reference

createSynq(config) → { verifySession, getUser, requireGuest, requireAccess, auth }

Factory. Call once. The returned object has all the bound DAL primitives.

OptionTypeDefaultNotes
clientIdstringrequired. Your OIDC client_id.
clientSecretstringrequired. Server-only.
appUrlstringrequired. Fully-qualified URL of your app. Used to compute the redirect URI.
sessionSecretstringrequired. ≥32 chars. Encrypts the iron-session cookie.
issuerstringhttps://api.synq.idOIDC issuer base URL.
scopesstring[]['openid', 'profile', 'email']Scopes to request at login.
authPathstring/api/authWhere the catch-all handler is mounted.
loginPathstring/api/auth/loginWhere unauthenticated users are sent.
homePathstring/Where signed-in requireGuest users go.
forbiddenPathstringhomePathWhere denied requireAccess users go.
sessionCookieNamestringsynq.sidCookie name. Change if you have multiple Synq apps on the same domain.

verifySession(): Promise<{ user, accessToken, idToken?, expiresAt, scopes? }>

Requires a session. Redirects to loginPath if none. Cached per render via React.cache(). Use at the top of every protected server component, Server Action, and Route Handler.

getUser(): Promise<SynqUser | null>

Non-throwing variant. Returns null if there’s no session. Use in UI that adapts to signed-in vs guest (header avatar / “Sign in” button).

requireGuest({ redirectTo? }?): Promise<void>

Inverse of verifySession. Used on pages a signed-in user shouldn’t see. Redirects to homePath (or redirectTo override) when a session exists.

requireAccess(predicate, { redirectTo? }?): Promise<SynqSession>

Verifies the session, then calls the predicate. Returns the session on true; redirects to forbiddenPath (or redirectTo override) on false. The predicate is sync or async, receives the verified session, and returns a boolean. The predicate is the only place permission logic lives — Synq does not introspect it.

auth = verifySession

Alias matching the NextAuth / Clerk muscle memory.

synqProxy({ publicPaths?, sessionCookieName?, loginPath?, chain? })

Builds the proxy function for src/proxy.ts. Cookie-presence check only. /api/auth/* is implicitly public — you cannot lock yourself out of the OIDC flow by misconfiguring the publicPaths list.

handlers(config)

Builds the GET and POST handlers for the catch-all route. The exact same config object you passed to createSynq — pass it in again here.

<SynqProvider user={user}>

Client component. Mount once below <html>/<body>. Seed with the SSR-fetched user. Re-renders when user changes.

useUser() → { user, isSignedIn }

Client hook. Read the user from anywhere below <SynqProvider>.

useSession() → { session: { user } | null }

Same as useUser but in the session-shaped envelope, for symmetry with verifySession. Tokens are deliberately not exposed client-side — use server primitives if you need to call your API with the access token.

Project structure recap

A working Synq-on-Next.js app has these five files. That’s it.

src/ ├── lib/ │ └── synq.ts ← createSynq(...) + exports ├── proxy.ts ← synqProxy(...) └── app/ ├── layout.tsx ← <SynqProvider user={await getUser()}> ├── api/auth/[...synq]/route.ts ← handlers(synqConfig) └── dashboard/page.tsx ← const { user } = await verifySession()

Common patterns

Adapting an existing layout

If your layout already does server-side fetching, just pass the result through:

import { getUser } from '@/lib/synq' import { SynqProvider } from '@synqid/nextjs/client' export default async function Layout({ children }) { const [user, appConfig] = await Promise.all([ getUser(), fetchAppConfig(), ]) return ( <html><body> <ConfigProvider config={appConfig}> <SynqProvider user={user}>{children}</SynqProvider> </ConfigProvider> </body></html> ) }

Order matters only insofar as SynqProvider must wrap any client component that calls useUser() — it can be anywhere below <html>.

Linking to a specific OAuth provider

/api/auth/login lands the user on the Synq login chooser. To skip that and go straight to Google (say), pass a provider query param:

<a href="/api/auth/login?provider=google">Continue with Google</a>

Synq’s login handler honors the provider param against your brand’s configured providers.

Forwarding the user back to where they were

/api/auth/login?returnTo=/dashboard will land the user on /dashboard after sign-in instead of the home page. Your proxy sets this automatically — anywhere a verifySession() redirects to login, the original path is captured.

Calling your API with the access token

For server-side calls, read the token from the session:

import { verifySession } from '@/lib/synq' const { accessToken } = await verifySession() const res = await fetch(`${API_URL}/me`, { headers: { authorization: `Bearer ${accessToken}` }, })

For client-side calls, route through a server action or Route Handler — never expose access tokens to the browser.

What this SDK does NOT do

  • It does not store sessions in a database. Sessions live in the iron-session encrypted cookie. If you need DB-backed sessions (e.g. server-side revocation across devices), use the bare @synqid/js SDK and wire your own storage.
  • It does not paginate user lists, manage organizations, or call any non-auth endpoint. Use @synqid/js for that.
  • It does not opine on what “permissions” mean. Pass a predicate to requireAccess.
  • It does not show a “Powered by Synq” badge. Anywhere.

Migrating from NextAuth / Clerk

The mental model maps cleanly:

ConceptNextAuth v5Clerk@synqid/nextjs
Catch-all handlerapp/api/auth/[...nextauth]/route.tsapp/api/clerk/[...clerk]/route.tsapp/api/auth/[...synq]/route.ts
Server session checkawait auth()await auth() / currentUser()await verifySession() (alias auth())
Client user hookuseSession()useUser()useUser()
Provider<SessionProvider><ClerkProvider><SynqProvider>
Sign-in route/api/auth/signin/sign-in/api/auth/login
Sign-outsignOut()signOut()link to /api/auth/logout

The biggest mental-model shift is requireAccess(predicate) instead of auth.protect({ permission: 'x' })-style role middleware. Synq deliberately doesn’t model permissions — your product does.

See Migrating from another provider for line-by-line diffs.

Last updated on