Sign users in to a Next.js app
Put a brand's sign-in in front of a Next.js app with @synqauth/nextjs, keep every token on the app's server, and call your API.
At 0.x
@synqauth/nextjs, @synqauth/react and @synqauth/js are on npm at 0.x, so a minor release can
change their API. The changelog says what each release changed.
@synqauth/nextjs is a backend-for-frontend: the app's server signs the person in, keeps their tokens in a sealed, HttpOnly session, and refreshes them, so the browser never holds a Synq token. The code on this page is the app Synq's own tests sign in with, against a real issuer, on every change.
You need an org and a brand on Synq, and an API key with org.apps.edit.
1. Register the app
Make an app of the brand: a web app, one of the brand's own (first_party), with the route mount's callback as its redirect URI and the app's root as its post-logout URI. $SYNQ_API here is the management API, https://api.synq.build.
curl -X POST "$SYNQ_API/v1/orgs/$ORG/brands/$BRAND/apps" \
-H "Authorization: Bearer $SYNQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "My app",
"app_type": "web",
"first_party": true,
"redirect_uris": ["https://app.example.com/api/auth/callback"],
"post_logout_redirect_uris": ["https://app.example.com/"]
}'The answer has the app's client_id and its client_secret, which Synq shows only this once.
2. Install and configure
pnpm add @synqauth/nextjs @synqauth/reactSet five environment variables, on the server only:
| Variable | What it is |
|---|---|
SYNQ_ISSUER | The brand's issuer, such as https://acme.auth.synq.build |
SYNQ_CLIENT_ID | The app's client_id |
SYNQ_CLIENT_SECRET | The app's client_secret |
SYNQ_APP_URL | Where the app is served, such as https://app.example.com: its callback and post-logout URIs are made from it |
SYNQ_SESSION_SECRET | At least 32 random bytes the session is sealed with: openssl rand -base64 32 |
One configuration, which the server and the proxy share:
import { defineSynqConfig } from '@synqauth/nextjs/config'
export const synqConfig = defineSynqConfig({})import { synqAuth } from '@synqauth/nextjs'
import { synqConfig } from '../synq.config'
export const synq = synqAuth(synqConfig)3. Mount the routes
The route mount signs people in and out, finishes the callback, and answers @synqauth/react for the browser:
import { synq } from '../../../../lib/synq'
export const { GET, POST, PATCH, DELETE } = synq.handlers4. Protect pages
proxy.ts refreshes a session that's due before a page renders, and sends anyone without one from the pages you protect to sign in. Next 16 runs it on Node; it imports nothing only Node has, so the Edge runtime works too.
import { synqProxy } from '@synqauth/nextjs/proxy'
import { synqConfig } from './synq.config'
export const proxy = synqProxy(synqConfig, { protect: ['/account', '/manage'] })
// Pages only: not Next's files, nor anything with an extension.
export const config = { matcher: ['/((?!_next/|.*\\..*).*)'] }5. Sign in, read who's there, sign out
In a server component, await synq.me() is the signed-in person or null, read from the session with no request to Synq. Link synq.signInPath() to sign in, and sign out with a form that posts to synq.signOutPath.
import { synq } from '../lib/synq'
export default async function Home() {
const me = await synq.me()
return (
<>
<h1>Next.js app</h1>
<p id="who">{me ? `Signed in as ${me.sub}` : 'Signed out'}</p>
<a href={synq.signInPath({ returnTo: '/account' })}>Sign in</a>
</>
)
}import { synq } from '../../lib/synq'
export default async function Account() {
const me = await synq.me()
return (
<>
<h1>Account</h1>
<p id="who">{me ? `Signed in as ${me.sub}` : 'Signed out'}</p>
<form method="post" action={synq.signOutPath}>
<button type="submit">Sign out</button>
</form>
</>
)
}signInPath also takes method (open the sign-in page at one method, such as passkey), loginHint, prompt and maxAge. Signing out ends the app's session, then sends the person to the brand's sign-out page, which ends the brand's session and brings them back to SYNQ_APP_URL.
6. Add the account components
@synqauth/react talks to the app's own route mount, never to Synq. Give the pages that need the session a provider, in a route group's layout, so the rest of the app stays static:
import { SynqProvider } from '@synqauth/react'
import type { ReactNode } from 'react'
import { synq } from '../../lib/synq'
export default async function SignedInLayout({ children }: { children: ReactNode }) {
return <SynqProvider initialSession={await synq.getSession()}>{children}</SynqProvider>
}import '@synqauth/react/styles.css'
import { AccountManager, UserButton, WalletManager } from '@synqauth/react'
export default function Manage() {
return (
<>
<header>
<UserButton accountHref="/manage" />
</header>
<AccountManager />
<WalletManager />
</>
)
}The components' look comes from @synqauth/react/styles.css, themed with CSS variables (--synq-color-primary, --synq-radius…), or none with unstyled. The hooks are useSession(), useUser(), useWallets(), useLinkedAccounts(), useAccountSessions() and useAuth(): each answers { data, error } and never throws. To let the person add a way to sign in (Google, a wallet, anything the brand offers), call useAuth().link('google'), or POST { method, returnTo } as JSON to /api/auth/link and send the browser to the redirect it answers: Synq asks them to sign in again, then adds it. See linking.
7. Call your API
Register your API with the brand (Register an API), and name it and its scopes in the configuration:
export const synqConfig = defineSynqConfig({
resource: 'https://api.example.com',
scope: 'orders.read',
})On the app's server, await synq.getAccessToken() is a current token for it, refreshed when it must be: send it as Authorization: Bearer, and never hand it to the browser. Your API checks it with @synqauth/js/server, against the issuer's keys, with no request to Synq per token:
import { createAccessTokenVerifier, isSynqError } from '@synqauth/js/server'
// One per API, kept for the process: it caches the issuer's keys.
const verify = createAccessTokenVerifier({
issuer: process.env.SYNQ_ISSUER!,
audience: 'https://api.example.com',
})
export async function GET(request: Request): Promise<Response> {
const token = request.headers.get('authorization')?.replace(/^Bearer /, '')
if (!token) return new Response(null, { status: 401 })
try {
const claims = await verify(token, { scopes: ['orders.read'] })
return Response.json({ orders: [], for: claims.sub })
} catch (error) {
if (isSynqError(error)) return new Response(null, { status: error.status ?? 401 })
throw error
}
}It checks the signature (ES256), iss, aud, the expiry and the scopes you ask for; authorizedParties limits a route to some apps, and maxAuthAgeSeconds asks for a recent sign-in. See security for why such a token can't be revoked early.
8. More than one server process: use a store
By default the session lives in its sealed cookie, and only one refresh runs at a time within one server process. Synq rotates refresh tokens and ends a session whose old one comes back, so an app on more than one process (Vercel and other serverless hosts, replicas) must keep sessions in a store they all share, which gives each session one lock:
import { keyValueSessionStore } from '@synqauth/nextjs'
import { defineSynqConfig } from '@synqauth/nextjs/config'
export const synqConfig = defineSynqConfig({
store: keyValueSessionStore({
get: (key) => redis.get(key),
set: async (key, value, { ttlMs, onlyIfAbsent }) =>
(await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
delete: async (key) => void (await redis.del(key)),
}),
})synqAuth() refuses cookie sessions where VERCEL, AWS_LAMBDA_FUNCTION_NAME, NETLIFY, K_SERVICE or KUBERNETES_SERVICE_HOST is set, unless allowSingleProcessCookieMode says the app really runs one process. A store also lets your server end a session, which a cookie can't.
To rotate SYNQ_SESSION_SECRET, move the old one to SYNQ_SESSION_SECRET_PREVIOUS (comma-separated), which opens sessions but never seals them, and drop it once those sessions have ended.
Developing on your laptop
A web app's redirect URIs are https, on a loopback address too: register https://localhost:3000/api/auth/callback and https://localhost:3000/ on an app of its own for development, never on your production app (deployed, such an app carries a loopback_redirect_uris warning). Then serve the app over https:
SYNQ_APP_URL=https://localhost:3000 next dev --experimental-httpswith that app's SYNQ_CLIENT_ID and SYNQ_CLIENT_SECRET.