@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.txtfor 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 theNEXT_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/nextjsRequires 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:
| Route | Method | What it does |
|---|---|---|
/api/auth/login | GET | Start auth-code + PKCE, redirect to Synq |
/api/auth/callback | GET | Exchange code for tokens, set session cookie |
/api/auth/logout | GET, POST | Clear session, RP-initiated logout |
/api/auth/session | GET | JSON { 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.
| Option | Type | Default | Notes |
|---|---|---|---|
clientId | string | — | required. Your OIDC client_id. |
clientSecret | string | — | required. Server-only. |
appUrl | string | — | required. Fully-qualified URL of your app. Used to compute the redirect URI. |
sessionSecret | string | — | required. ≥32 chars. Encrypts the iron-session cookie. |
issuer | string | https://api.synq.id | OIDC issuer base URL. |
scopes | string[] | ['openid', 'profile', 'email'] | Scopes to request at login. |
authPath | string | /api/auth | Where the catch-all handler is mounted. |
loginPath | string | /api/auth/login | Where unauthenticated users are sent. |
homePath | string | / | Where signed-in requireGuest users go. |
forbiddenPath | string | homePath | Where denied requireAccess users go. |
sessionCookieName | string | synq.sid | Cookie 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/jsSDK and wire your own storage. - It does not paginate user lists, manage organizations, or call
any non-auth endpoint. Use
@synqid/jsfor 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:
| Concept | NextAuth v5 | Clerk | @synqid/nextjs |
|---|---|---|---|
| Catch-all handler | app/api/auth/[...nextauth]/route.ts | app/api/clerk/[...clerk]/route.ts | app/api/auth/[...synq]/route.ts |
| Server session check | await auth() | await auth() / currentUser() | await verifySession() (alias auth()) |
| Client user hook | useSession() | useUser() | useUser() |
| Provider | <SessionProvider> | <ClerkProvider> | <SynqProvider> |
| Sign-in route | /api/auth/signin | /sign-in | /api/auth/login |
| Sign-out | signOut() | 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.