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
- Authentication — the token broker, in full.
- Pin a dispatch target — skip loopback discovery, and the 3-second poll with it.
FingerprintReaderConfig— every construction option.