Server-side integration

For apps that have their own backend — a Next.js API route, an Express server, a Hono handler — and need to know who the logged-in user is before touching a database row. The client SDK handles signup/login on the browser; the server SDK answers the question "is this Bearer token real, and whose is it?" on every request.

Typical shape: Pluralize owns auth and billing; your app owns the domain. The browser logs in via Pluralize, your API routes verify the token and then read/write your own database scoped by the verified tenantId.

Install

npm install @pluralize/sdk

The same package ships three entry points:

  • @pluralize/sdk — browser client (auth, data, billing, files).
  • @pluralize/sdk/react — drop-in React components.
  • @pluralize/sdk/server — server-side token verification. No React, no DOM, no side effects at import time.

Configure

Two values, grabbed from the Pluralize dashboard for your app:

  • appId — the UUID of your app.
  • apiKey — the publishable key (pk_live_... or pk_test_...).

Keep them both in env vars. They are not secret in the cryptographic sense (the key is publishable) but they identify your app and should not get mixed up with another app's values.

NEXT_PUBLIC_PLURALIZE_APP_ID=...
NEXT_PUBLIC_PLURALIZE_API_KEY=...

Verify a Bearer token in an API route

// lib/pluralize.ts
import { createPluralizeServer } from '@pluralize/sdk/server';
 
export const pluralize = createPluralizeServer({
  appId: process.env.NEXT_PUBLIC_PLURALIZE_APP_ID!,
  apiKey: process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
});
// app/api/recipes/route.ts
import { pluralize } from '@/lib/pluralize';
import { sql } from '@/lib/db';
 
export async function GET(req: Request) {
  const session = await pluralize.verifyToken(req);
  if (!session) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }
 
  const rows = await sql`
    SELECT id, title FROM recipes WHERE tenant_id = ${session.tenantId}
  `;
  return Response.json({ recipes: rows });
}

verifyToken returns { tenantId, appId, exp } | null:

  • null means the request has no valid session. Reject.
  • session.tenantId is the identifier for the logged-in user in your tables. Pluralize is one-user-per-tenant today; userId will be added as a distinct field later if multi-user tenants land.

Tier-aware checks in one hop

If you need the user's plan (to enforce usage caps, toggle features, etc.), ask for subscription in the same call. The endpoint will include the plan slug and the entitlement map without a second round-trip.

const session = await pluralize.verifyToken(req, {
  include: ['subscription'],
});
if (!session) return unauthorized();
 
const { planSlug, entitlements } = session.subscription!;
const dailyTokens = (entitlements.ai_daily_tokens as number | undefined) ?? 0;

Plan slugs are whatever you configure in the dashboard when you define the plans for your app. There is no global registry; planSlug is opaque to Pluralize and meaningful only to your app.

Entitlement value shapes

entitlements is Record<string, boolean | number> after verification — the values are already coerced from the dashboard's 'true'/'false' / numeric strings into native types. So entitlements.export_csv === true and entitlements.max_recipes >= 100 work directly without parsing.

If a plan doesn't define a given key, it's simply absent from the map. Use ?? defaults to handle "user is on the free plan that hasn't enabled this yet" without crashing:

const aiEnabled = entitlements.ai_enabled === true;          // boolean default false
const maxRecipes = (entitlements.max_recipes as number) ?? 0; // number default 0

Server-side entitlement gating

Two patterns to know — pick per check. Both assume the snippet above ran and we have session with subscription.

Hard cap at insert time

For caps tied to a collection's row count, Pluralize enforces them automatically. max_<collection> entitlements (e.g. max_recipes) cause the data SDK to reject inserts with 402 limit_reached once the cap is hit — no code on your side. Just catch the error and surface a friendly message:

try {
  await app.db.collection('recipes').create({ title });
} catch (err) {
  if (err instanceof PluralizeError && err.code === 'limit_reached') {
    return Response.json({ error: 'plan_cap_reached' }, { status: 402 });
  }
  throw err;
}

The server's 402 response body actually includes { feature, limit, current }. The current SDK only surfaces code and status on PluralizeError, so if you need the limit/current numbers (e.g. to render "23 of 20 used") call the data endpoint directly via fetch() and parse the JSON yourself.

Manual cap for windowed quotas

For quotas you reset (daily AI calls, monthly exports), Pluralize hands you the cap but doesn't track usage windows. You count in your own table and gate before the expensive operation:

// app/api/ai/parse-recipe/route.ts
import { pluralize } from '@/lib/pluralize';
import { sql } from '@/lib/db';
 
export async function POST(req: Request) {
  const session = await pluralize.verifyToken(req, { include: ['subscription'] });
  if (!session) return Response.json({ error: 'unauthorized' }, { status: 401 });
 
  const dailyCap = (session.subscription?.entitlements.ai_daily_parses as number) ?? 0;
  if (dailyCap === 0) {
    return Response.json({ error: 'plan_required' }, { status: 402 });
  }
 
  const [{ count }] = await sql`
    SELECT count(*)::int FROM ai_usage
    WHERE tenant_id = ${session.tenantId}
      AND created_at >= now() - interval '24 hours'
  `;
  if (count >= dailyCap) {
    return Response.json(
      { error: 'limit_reached', limit: dailyCap, current: count, resetsAt: 'rolling 24h' },
      { status: 429 },
    );
  }
 
  // ... do the work, then INSERT INTO ai_usage ...
}

Boolean feature gates

For feature flags (export_csv, webhook_enabled, etc.), check before doing the work:

if (session.subscription?.entitlements.export_csv !== true) {
  return Response.json({ error: 'plan_required', feature: 'export_csv' }, { status: 402 });
}

Server vs client gating — when to use which

| Layer | Use for | Why | | --- | --- | --- | | Server (verifyTokenentitlements) | Hard caps. Anything that costs you money (AI calls, storage). Anything that bypasses paid features. | The browser can't be trusted to enforce limits against itself. | | Client (app.billing.hasFeature(...)) | UI affordances: hiding buttons, showing upgrade banners, toggling tooltips. | Avoids round-trips for cosmetic decisions. The server still gates the actual operation. |

Both read the same entitlements map — the difference is only where the decision happens. Always do the real check on the server; the client check is a UX optimization.

Cache tuning

Every verifyToken call hits Pluralize once per access token per cache window (30 s by default). In-process, keyed by the token itself. A user whose access token rotates every 15 min (default) will hit Pluralize roughly twice per rotation, regardless of how many requests they fire in between.

createPluralizeServer({
  appId, apiKey,
  cacheSeconds: 60,  // stretch cache for lower hop frequency
  // cacheSeconds: 0,  // disable caching entirely
});

cacheSeconds: 0 is useful in tests or when you need subscription changes to be visible immediately on the next request.

Errors

  • Network failure reaching Pluralize → throws PluralizeError with code: 'introspect_failed'. Up to you to fail open or closed.
  • 403 app_mismatch → the Bearer token was issued for a different app than the one owning your apiKey. Fix your env vars; this is a misconfiguration, not a user issue.
  • Any other non-2xx → PluralizeError with the server's code if present.

Browser integration

The client SDK lives on the same origin as your app, but the Pluralize backend lives at pluralize.app. That's a cross-origin call. There are two supported paths — pick the one that fits your architecture.

Path A — Cookie + server proxy (recommended for SSR)

Best for Next.js / Remix / SvelteKit apps that already have a server tier. Auth calls go through your own server, the access token rides in an httpOnly cookie, and pluralize.verifyToken(req) reads it for you. The SDK ships a helper that builds all five proxy endpoints for you — no manual cookie wrangling.

1. Drop in the prebuilt handlers. createAuthRouteHandlers returns Web Standard Response-based functions for signup, login, logout, exchange, and me. Wire each one to its route file (Next.js shown; same pattern works in Hono, Bun, Cloudflare Workers, etc.):

// lib/pluralize-auth.ts
import { createAuthRouteHandlers } from '@pluralize/sdk/server';
 
export const auth = createAuthRouteHandlers({
  appId: process.env.PLURALIZE_APP_ID!,
  apiKey: process.env.PLURALIZE_API_KEY!,
  cookieName: 'pz_token',
});
// app/api/auth/login/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.login;
// app/api/auth/signup/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.signup;
// app/api/auth/logout/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.logout;
// app/api/auth/exchange/route.ts — used after client-side login to set the cookie
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.exchange;
// app/api/auth/me/route.ts
import { auth } from '@/lib/pluralize-auth';
export const GET = auth.me;

If you've enabled tenant-mintable invites (see Authentication → Tenant viral invites), expose the matching pair under any path you like — convention is /invite-tokens:

// app/api/auth/pluralize/invite-tokens/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.mintInvite;       // body: { note?, expiresInSeconds? }
export const GET  = auth.listMyInvites;    // returns { tokens, quota }
// app/api/auth/pluralize/invite-tokens/[id]/route.ts
import { auth } from '@/lib/pluralize-auth';
export const DELETE = auth.revokeInvite;   // returns { revoked, quota }

Both handlers read the access token from the pz_token cookie and forward to Pluralize's tenant-scoped endpoints with the proper Bearer header. Necessary for SSR/cookie consumers because the browser SDK can only attach the token when it lives in localStorage.

For the verify-email banner, expose auth.resendVerification so logged-in tenants can re-trigger the verification email:

// app/api/auth/pluralize/resend-verification/route.ts
import { auth } from '@/lib/pluralize-auth';
export const POST = auth.resendVerification;
// body: { locale? } — passes through to /api/v1/auth/resend-verification
// 200 ok / 409 already_verified / 429 rate_limited / 402 quota_exceeded

To gate the banner without an extra round-trip, pass include: ['tenant'] to verifyToken in the layout/RSC that renders it:

const session = await pluralize.verifyToken(req, { include: ['tenant'] });
const showBanner = session?.tenant?.emailVerifiedAt === null;

session.tenant is { id, email, emailVerifiedAt, createdAt }. The introspect call accepts 'subscription' and 'tenant' together, so a single verifyToken({ include: ['subscription', 'tenant'] }) answers both "are they on the right plan?" and "have they verified their email?".

The helpers manage the httpOnly pz_token cookie (and an optional non-httpOnly pz_email cookie for showing "Signed in as ..." in your UI without a round-trip). TTLs match the access token's exp. Errors from Pluralize bubble up with their original code and HTTP status.

2. Tell the server SDK to read the cookie. Pass the same cookieName so verifyToken falls back to it when no Authorization: Bearer header is present:

// lib/pluralize.ts
import { createPluralizeServer } from '@pluralize/sdk/server';
 
export const pluralize = createPluralizeServer({
  appId: process.env.PLURALIZE_APP_ID!,
  apiKey: process.env.PLURALIZE_API_KEY!,
  cookieName: 'pz_token',
});

That's it — the rest of your API routes call pluralize.verifyToken(req) and the cookie flows through transparently.

Path B — CORS allowlist (no proxy)

If you don't have a server tier or you want the browser SDK to call Pluralize directly, allowlist your origin in the dashboard at API Keys → Browser CORS allowlist. Add entries like https://app.example.com (origin only — no path). Pluralize will return the matching Access-Control-Allow-Origin header on /api/v1/auth/* responses.

The browser SDK then just works — app.auth.login(...) posts directly to pluralize.app. Tokens live in localStorage (not httpOnly), so this trades the cookie isolation of Path A for simpler infra. Pick this path for SPAs, static sites, or any consumer where adding a server tier is overkill.

Which path is right?

  • You have API routes already → Path A. The cookie flows naturally to your server, works with SSR / RSC, and keeps the access token off window.
  • You're a pure client-side app or you want the smallest possible integration → Path B.
  • You want both (e.g. <PluralizeLogin /> in the browser plus pluralize.verifyToken on your server) → use Path A: inject a custom TokenStorage into Pluralize.init({ ..., storage }) that POSTs the token to your exchange endpoint on setTokens. The browser still gets the standard SDK ergonomics; the server gets the cookie.

What does NOT change

The client SDK on the browser is unchanged by using the server entry. Users still log in via app.auth.login(...) from @pluralize/sdk, their tokens still live in localStorage (or your injected TokenStorage), and cross-tab sync still works. The server entry only adds a verification path on your backend.

Future: verifying locally (JWKS)

The current model is one HTTP hop per cache window. When Pluralize has two or more consumer apps, we plan to publish a JWKS endpoint so consumers can verify tokens locally without any hop at all. That change will be transparent to the SDK — no code change on your side. The 30 s cache that works today is the cost floor; JWKS will only make it cheaper.