Write a plugin
Add your own routes to @synqauth/nextjs's route mount, call them from the browser, and use them from React, with every token staying on the server.
At 0.x
The @synqauth/* packages are on npm at 0.x, so a minor release can change their API. The
changelog says what each release changed.
A plugin adds routes to the app's route mount (/api/auth by default), and actions the page calls them with. Synq's own plugins are built this way: Sign In With Solana, passkeys and magic links. Write one when the page needs something only the server knows, such as who is signed in and how, or a call to an API with the person's token, without that token ever reaching the browser.
A plugin has two halves, which share one id:
- The server half (
defineSynqPluginfrom@synqauth/nextjs) is routes at<mount>/<id>/<path>. Each route is handed the session: the signed-in person, and a current access token for an API. A plugin can have no server half if it only calls the mount's own routes, as the passkey and magic-link plugins do. - The browser half (
defineSynqClientPluginfrom@synqauth/nextjs/client) is actions over those routes. It calls the app's own origin, with the session's cookie, and never sees a token.
The code on this page is a plugin in the app Synq's own tests run against a real issuer, on every change: sign-in-age, which tells the page when and how the person last signed in.
The server half
import { defineSynqPlugin } from '@synqauth/nextjs'
// The plugin's server half: one route under the app's route mount,
// GET /api/auth/sign-in-age/status. It reads the session on the server and
// answers only what the page needs, so no token ever reaches the browser.
export const signInAgePlugin = defineSynqPlugin({
id: 'sign-in-age',
routes: {
'GET /status': async (context) => {
const user = await context.user()
const headers = { 'cache-control': 'no-store' }
if (!user) return Response.json({ signedIn: false }, { headers })
// auth_time is when the person last proved who they are, in seconds.
const seconds =
user.auth_time === undefined ? null : Math.floor(Date.now() / 1000) - user.auth_time
return Response.json({ signedIn: true, seconds, methods: user.amr ?? [] }, { headers })
},
},
})- The id starts with a lowercase letter and is lowercase letters, digits and dashes, up to 40 characters. It can't be one of the mount's own routes:
sign-in,callback,sign-out,session,accountorlink.defineSynqPluginchecks it, and each route's key, when the module loads. - Each route is keyed
"<METHOD> /<path>", withGET,POST,PATCHorDELETE, the methods the route mount exports. It takes the plugin's context and theRequest, and answers aResponse. - The context gives
user(), the signed-in person from the ID token (ornull), andgetAccessToken({ resource }), a current access token for one of the APIs the sign-in asked for (the main one by default), refreshed when it must be. It also hasissuer,clientIdandbasePath. - The mount guards every route the same way. A request that changes something (anything but
GETandHEAD) must come from the app's own pages, or it's refused with403. A route that doesn't exist is404. Every answer getsnosniff, andno-storeunless the route sets its owncache-control. ASynqErrora route throws (fromgetAccessToken, say) becomes a problem document with itscode.
Add the server half to the app's configuration, beside any other plugin:
import { defineSynqConfig } from '@synqauth/nextjs/config'
import { signInAgePlugin } from './plugins/sign-in-age/server'
export const synqConfig = defineSynqConfig({
plugins: [signInAgePlugin],
})The browser half
import { defineSynqClientPlugin } from '@synqauth/nextjs/client'
// The plugin's browser half: actions over its server half's routes. Its id is
// the server half's, so `request('sign-in-age/status')` reaches that route.
/** What the status route answers. */
export interface SignInAge {
readonly signedIn: boolean
/** Seconds since the person last signed in, or null when Synq didn't say. */
readonly seconds?: number | null
/** How they signed in, as the ID token's `amr` says: `otp`, `swk`, `fed`... */
readonly methods?: readonly string[]
}
export const signInAge = defineSynqClientPlugin({
id: 'sign-in-age',
actions: (request) => ({
// An arrow function, so a component can take it out of the actions.
status: async (): Promise<SignInAge> => {
const response = await request('sign-in-age/status')
if (!response.ok) throw new Error(`sign-in-age answered ${response.status}`)
return (await response.json()) as SignInAge
},
}),
})actions is handed request, which calls the mount from the page: same origin, with the cookie, never cached, and with paths relative to the mount, so a plugin's own routes start with its id. Its actions can be anything. Synq's own plugins answer { data, error } from every call that reaches the mount, as @synqauth/react's hooks do; this one throws, to stay short.
Without React, call actions yourself with mountRequest() from @synqauth/nextjs/client, which takes the mount's path if it isn't /api/auth:
import { mountRequest } from '@synqauth/nextjs/client'
import { signInAge } from './plugins/sign-in-age/client'
const { status } = signInAge.actions(mountRequest())From React
Pass the browser half to <SynqProvider plugins>, and usePlugin hands a component its actions, typed by the plugin:
'use client'
import { SynqProvider, usePlugin } from '@synqauth/react'
import { useState } from 'react'
import { signInAge } from '../../plugins/sign-in-age/client'
// The plugin from React: the provider takes its browser half, and usePlugin
// hands a component its actions, typed by the plugin.
function Status() {
const { status } = usePlugin(signInAge)
const [text, setText] = useState('')
async function check() {
const age = await status()
if (!age.signedIn) return setText('Not signed in.')
setText(`Signed in ${age.seconds ?? '?'} s ago with ${age.methods?.join(', ') || 'unknown'}.`)
}
return (
<>
<button type="button" onClick={() => void check()}>
How did I sign in?
</button>
<p id="sign-in-age">{text}</p>
</>
)
}
export function SignInAgeStatus() {
return (
<SynqProvider plugins={[signInAge]}>
<Status />
</SynqProvider>
)
}A plugin holds functions, which a server component can't pass to a client one. So when the provider sits in a server layout, as it does to take initialSession, wrap it in a client component of your own that adds plugins. usePlugin throws when the provider wasn't given that plugin.
What a plugin should never do
- Hand a token to the browser. Answer what the page needs, computed on the server. The route mount's
sessionroute never answers a token either. - Verify a sign-in itself. The issuer runs every sign-in step and checks it; a plugin sends the person there (as the magic-link plugin does) or hands what the browser gathered to it (as Sign In With Solana does, and the passkey plugin through own-UI sign-in).
- Trust the page for who someone is. Read the person from
context.user(), never from the request's body.