Skip to Content
Web SDKGuidesUse with SSR frameworks

Use the SDK with SSR frameworks

The SDK is browser-only. Capture sessions need MediaDevices, Worker, and WebAssembly; the fingerprint reader reaches http://localhost:18065 and starts a background timer. None of that exists in a Node render pass, so nothing from this SDK may be constructed while your framework is rendering on the server.

Importing the package is fine. Constructing is not.

The specific hazard: construction starts a timer

createFingerprintReader (and sladeId.reader) begins polling the local hardware service every 3 seconds, starting on the next tick after construction. That is not lazy and there is no “start” call to defer it.

So a module-scope reader is a live poll loop attached to the module, not to your page:

// ✗ Do not do this. // Evaluated when the module is first imported — which, in an SSR framework, // may be during a server render, during a route prefetch, or in a build step. export const reader = createFingerprintReader({ getToken, middlewareUrl });

On the server that throws or hangs on fetch to loopback. In the browser it starts polling before the user has navigated to the screen that needs a scanner, and keeps polling after they leave — one timer per import, never disposed.

A poll-failed event every 3 seconds against a host with no companion application is not harmless: it is a stream of failed requests, a noisy error banner if you wired one up, and log volume in whatever telemetry you have.

Construct lazily behind a typeof window guard

Create the client on first use, from code that can only run in the browser, and memoise it so repeated calls share one instance and one timer.

// lib/reader.ts 'use client'; import { createFingerprintReader, type FingerprintReaderClient } from '@sladeid/slade-id-sdk'; let client: FingerprintReaderClient | null = null; /** * Returns the process-wide reader, creating it on first call. * * The `typeof window` guard is the real safety net: `'use client'` marks the * module as client-capable, but Next.js still evaluates client modules on the * server during SSR. Only the call itself is browser-only. */ export function getReader(): FingerprintReaderClient { if (typeof window === 'undefined') { throw new Error('getReader() is browser-only — call it from an effect or an event handler'); } client ??= createFingerprintReader({ // Your server route brokers the token; see below. getToken: async () => { const res = await fetch('/api/sladeid/token', { credentials: 'same-origin' }); if (!res.ok) throw new Error(`token endpoint failed: ${res.status}`); return (await res.json()).access_token as string; }, middlewareUrl: process.env.NEXT_PUBLIC_SLADEID_MIDDLEWARE_URL!, }); return client; } /** Call on teardown if the reader is scoped to one screen rather than the app. */ export function disposeReader(): void { client?.dispose(); client = null; }

Then call it from an effect or an event handler — never from a component body, which runs on the server too:

// app/(till)/scan/page.tsx 'use client'; import { useEffect, useState } from 'react'; import { getReader } from '@/lib/reader'; export default function ScanPage() { const [ready, setReady] = useState(false); useEffect(() => { // Effects never run on the server, so this is the earliest safe point. const reader = getReader(); const onConnected = () => setReady(true); reader.on('connected', onConnected); return () => { reader.off('connected', onConnected); }; }, []); return <button disabled={!ready}>Identify</button>; }

'use client' alone is not the guard. It marks a module as client-capable, but Next.js App Router still evaluates client modules during the server render to produce HTML — so module-scope side effects still fire on the server. The typeof window check is what actually holds.

The same shape works in any framework that renders on the server: SvelteKit (browser from $app/environment), Nuxt (import.meta.client, or a plugin with .client.ts), Remix / React Router (useEffect), Astro (client:only). The rule does not change: construct from browser-only code, memoise, dispose on teardown.

A pinned dispatch target removes the poll entirely

If you supply workstationId and deviceId, no /status polling ever starts — there is nothing to discover. Construction becomes cheap, which removes the cost of an accidentally-early instance.

It does not remove the need for the guard. Nothing about the client is server-safe, and the token fetch still needs a browser. Keep the lazy pattern; treat the missing poll as one less thing to leak. See Pin a dispatch target.

Broker the token from a server route

Do not put a Keycloak client secret in a client module. Anything reachable from browser code — including process.env values inlined with a public prefix such as NEXT_PUBLIC_ — ships in the bundle and is readable in dev tools.

Put the client_credentials exchange in a server route and return only the short-lived access token, gated by your own session:

// app/api/sladeid/token/route.ts — server-only; never bundled for the browser import { NextResponse } from 'next/server'; export async function GET() { // Gate on your own session first; this endpoint mints Slade ID credentials. const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.SLADEID_CLIENT_ID!, // no NEXT_PUBLIC_ prefix client_secret: process.env.SLADEID_CLIENT_SECRET!, }); const res = await fetch(`${process.env.KEYCLOAK_TOKEN_URL}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, }); const token = await res.json(); // Return the access token only — never the refresh token or the secret. return NextResponse.json( { access_token: token.access_token }, { headers: { 'cache-control': 'no-store' } } ); }

getToken is re-invoked whenever the middleware answers 401, so short-lived tokens self-heal and you never need a long-lived one in the browser. The full flow, including an RFC 8693 variant that preserves the end user’s identity, is in Authentication.

Checklist

  • Nothing from the SDK is constructed at module scope.
  • Construction happens behind typeof window !== 'undefined', from an effect or an event handler.
  • The instance is memoised, so one screen does not create several poll loops.
  • dispose() runs on teardown if the client is screen-scoped.
  • The client secret lives only in server-side environment variables, and the browser sees only an access token.

See also