Frameworks

Express, Next.js, Hono, SvelteKit, Remix, Bun, Deno, Cloudflare Workers.

Two adapter shapes cover everything: Node-stream middleware, and a fetch handler. Pick the one your framework speaks.

Express, Connect, Fastify

import express from 'express';
import { PasskeyServer } from 'passkify';
 
const app = express();
app.use(express.json());
 
app.use(passkeys.express({
  basePath: '/passkey',
  getSessionUserId: (req) => req.session.userId ?? null,
  onLogin:    (req, res, { user }) => { req.session.userId = user.id; },
  onRegister: (req, res, { user }) => { req.session.userId = user.id; },
}));

The middleware calls next() for anything that is not a passkey route, so it is safe at the top of the stack.

For Fastify, use @fastify/middie or fastify-express to mount Connect-style middleware, or call the four methods directly from a Fastify route.

Next.js App Router

Create the shared instance

// lib/passkeys.ts
import { PasskeyServer } from 'passkify';
import { store } from './store';
 
const globalForPasskeys = globalThis as unknown as { passkeys?: PasskeyServer };
 
export const passkeys =
  globalForPasskeys.passkeys ??
  new PasskeyServer({
    rpName: 'Acme',
    origin: process.env.NEXT_PUBLIC_ORIGIN!,
    store,
  });
 
if (process.env.NODE_ENV !== 'production') {
  globalForPasskeys.passkeys = passkeys;
}

The globalThis stash matters in development: Next re-evaluates modules on every edit, and a fresh MemoryStore per edit discards every in-flight challenge.

Mount the catch-all route

// app/api/passkey/[...passkey]/route.ts
import { cookies } from 'next/headers';
import { passkeys } from '@/lib/passkeys';
 
const handler = passkeys.handler({
  basePath: '/api/passkey',
  getSessionUserId: async () => (await cookies()).get('session')?.value ?? null,
  onLogin: async (_request, { user }) => {
    (await cookies()).set('session', user.id, {
      httpOnly: true, secure: true, sameSite: 'lax', path: '/',
    });
  },
});
 
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
 
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

Point the client at it

'use client';
import { configure, login } from 'passkify/client';
 
configure({ baseUrl: '/api/passkey' });   // must match basePath

The three things that trip people up

basePath must match the file path404 otherwise
Ceremonies must run client-sidebuild error otherwise
runtime must be nodejsnot edge

Hono

import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
 
const app = new Hono();
 
const handler = passkeys.handler({
  basePath: '/passkey',
  getSessionUserId: (request) => readSessionFrom(request),
});
 
app.all('/passkey/*', (c) => handler(c.req.raw));

To set a cookie, return a HeadersInit from onLogin:

onLogin: (_request, { user }) => ({
  'set-cookie': `session=${user.id}; HttpOnly; Secure; Path=/; SameSite=Lax`,
}),

SvelteKit

// src/routes/passkey/[...path]/+server.ts
import { passkeys } from '$lib/server/passkeys';
 
const handler = passkeys.handler({ basePath: '/passkey' });
 
export const GET = ({ request }) => handler(request);
export const POST = ({ request }) => handler(request);
export const PATCH = ({ request }) => handler(request);
export const DELETE = ({ request }) => handler(request);

Import from $lib/server/ so SvelteKit's module boundary keeps the server half out of the client bundle for you.

Remix and React Router

// app/routes/passkey.$.tsx
const handler = passkeys.handler({ basePath: '/passkey' });
 
export const loader = ({ request }) => handler(request);
export const action = ({ request }) => handler(request);

Bun

const handler = passkeys.handler({ basePath: '/passkey' });
 
Bun.serve({
  port: 3000,
  fetch(request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/passkey')) return handler(request);
    return new Response('Not found', { status: 404 });
  },
});

Deno

import { PasskeyServer } from 'npm:passkify';
 
const handler = passkeys.handler({ basePath: '/passkey' });
Deno.serve((request) => handler(request));

Cloudflare Workers

No framework at all

The adapters are convenience. The four methods are the API.

import { createServer } from 'node:http';
 
createServer(async (req, res) => {
  if (req.url === '/passkey/login/start' && req.method === 'POST') {
    const { options } = await passkeys.startAuthentication();
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify(options));
    return;
  }
  // ...
}).listen(3000);

Or mount passkeys.express() on the bare node:http server, which works because the adapter parses its own body.