Synq Docs
Guides

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 (defineSynqPlugin from @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 (defineSynqClientPlugin from @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

src/plugins/sign-in-age/server.ts
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, account or link. defineSynqPlugin checks it, and each route's key, when the module loads.
  • Each route is keyed "<METHOD> /<path>", with GET, POST, PATCH or DELETE, the methods the route mount exports. It takes the plugin's context and the Request, and answers a Response.
  • The context gives user(), the signed-in person from the ID token (or null), and getAccessToken({ 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 has issuer, clientId and basePath.
  • The mount guards every route the same way. A request that changes something (anything but GET and HEAD) must come from the app's own pages, or it's refused with 403. A route that doesn't exist is 404. Every answer gets nosniff, and no-store unless the route sets its own cache-control. A SynqError a route throws (from getAccessToken, say) becomes a problem document with its code.

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

src/plugins/sign-in-age/client.ts
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:

src/app/plugins/SignInAgeStatus.tsx
'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 session route 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.

On this page