This page is the long version. If you have an empty project and just want code, the
Quickstart is faster. If you have a single-user app you wrote for yourself and
now want to ship to real users, read this top to bottom — it's the path I wish I'd had
the first time.
The shortcut: if you'd rather not click through eleven steps, install the CLI
and let it drive — including a Claude Code skill that lets your AI editor do the
integration for you. Skip to the CLI page if that's you. The rest of
this guide explains what the automation is doing under the hood.
npm i -g @pluralize/clipluralize login # interactivepluralize init --yes # creates app + writes .env.local + CORS + snippetspluralize skill install # so Claude Code can finish the job: paste files, # detect hardcoded user IDs, propose the refactor
We'll follow Maya, a developer who built a Next.js recipe app over a long weekend.
It works for her: one user (her), recipes saved to a Postgres table she set up on Neon,
zero auth, zero billing. A friend asks if they can use it. Now she has a wall of
plumbing to climb. By the end of this page, she's shipped multi-tenant signup,
per-user data isolation, and a paid plan — without rewriting the parts of her app she
likes.
The whole walkthrough is grounded in code that actually compiles against
@pluralize/sdk today. No fictional Provider, no imaginary hooks.
What you'll have at the end
A live signup page on your domain.
A dashboard at pluralize.app/dashboard where you watch users arrive.
Recipe rows scoped per-user — no WHERE user_id = ? in your query code.
A "Free / Pro" tier with Stripe checkout wired through Pluralize's 5% application fee.
Verification email + login, including a "verify your email" banner pattern.
You should set aside about 45 minutes the first time. Subsequent apps take five.
Maya's starting point
Before she touches Pluralize, here's what her single-user app looks like. Yours probably
looks similar in shape — a single Next.js app, a database, a hardcoded user, no auth.
// app/api/recipes/route.ts — Maya's "before"import { sql } from '@/lib/db';const HARDCODED_USER_ID = 1; // 😬 the seed of all evilexport async function GET() { const rows = await sql` SELECT id, title, minutes FROM recipes WHERE user_id = ${HARDCODED_USER_ID} `; return Response.json({ recipes: rows });}export async function POST(req: Request) { const { title, minutes } = await req.json(); await sql` INSERT INTO recipes (user_id, title, minutes) VALUES (${HARDCODED_USER_ID}, ${title}, ${minutes}) `; return Response.json({ ok: true });}
If your app looks roughly like this — you have a frontend, you have an API route or
two, and the user is hardcoded somewhere — you're in scope for this guide.
You can sign up with GitHub (one click) or email + password. The GitHub flow
is faster and pre-fills your name; the email flow asks for name, email, and a
password of at least 10 characters. Either way, you land on the dashboard within
ten seconds.
Verifying your developer email. Pluralize sends a verification email to your
inbox. You don't need to click the link to keep working — every dashboard action
is unlocked immediately — but the link expires after 24 hours, so click it before
you forget.
Step 2 — Create your first app
The dashboard greets first-timers with a "Create your first app" card.
App Name — what you'd put on a T-shirt. "Recipe Box" for Maya.
Slug — auto-derived from the name. Lowercase, hyphens. Used in admin URLs.
Immutable later, so pick something stable.
Click Create App. Pluralize provisions:
A UUID appId (this is your app's stable identifier).
A publishable API key, formatted like pk_live_….
A default free plan that every signup lands on.
An empty CORS allowlist (we'll fix this in Step 4).
Save the publishable key the first time it's shown. The dashboard surfaces it
right after creation under Dashboard → API Keys. The key is only displayable in
plaintext while it's still in your session — once you close the tab, the only way
to see a key again is to click Regenerate, which immediately invalidates the
previous one. Copy it into a password manager (or your project's .env.local)
before you wander off.
Step 3 — Wire your environment
In your project root:
npm install @pluralize/sdk
Add the two values to .env.local. Both are read in the browser, so they MUST be
prefixed with NEXT_PUBLIC_ in App Router projects:
The publishable key is not a secret in the cryptographic sense. It identifies
your app, but it cannot mint sessions on its own — Pluralize gates every browser
call by your CORS allowlist (next step). The key shipping to a browser is the
intended design, not a leak.
Create a singleton SDK client. This is the file you'll import from every component
that talks to Pluralize:
That's the entire SDK setup — one file, ten lines.
Step 4 — Add your origin to the CORS allowlist
This is the step every developer forgets, and the one that produces the most
mystifying error: a CORS-blocked OPTIONS preflight that looks like a network bug
but is actually configuration.
In the dashboard, open API Keys → Browser CORS allowlist. Add every origin
your browser SDK will run from:
http://localhost:3000 (dev)
https://your-app.vercel.app (preview deploys)
https://recipebox.app (your production domain)
Origins are an exact match on scheme + host + port. Paths are not allowed. If you
see Pluralize calls fail in the browser with a CORS error, this list is almost
always why.
Why an allowlist exists. Without it, anyone could put your appId into their
page and use your monthly quota. The allowlist binds the publishable key to
origins you control.
Step 5 — Add a registration form
Now the part you've been waiting for: real users signing up. There are two ways to
do this. Pick the one that matches how much UI control you want.
Option A — drop-in (<PluralizeSignup />)
Fastest path, pre-styled, themeable. Good when you don't want to write your own
form.
The component handles email + password input, password-strength feedback,
duplicate-email handling, and the redirect-on-success callback. Themeable via the
theme prop or via the dashboard's Branding page.
Option B — your own form (app.auth.signup)
Full UI control. You write the JSX; the SDK handles the network + token storage.
After a successful signup, the SDK stores access + refresh tokens in localStorage
under a key namespaced by your appId. Every subsequent SDK call attaches them
automatically.
Step 6 — Track the logged-in user
The SDK doesn't ship a useAuth hook — you write a tiny one in your own code,
because it's three lines and lets you control re-render granularity. Drop this into
lib/use-current-user.ts once and reuse it everywhere:
// lib/use-current-user.ts'use client';import { useEffect, useState } from 'react';import type { TenantInfo } from '@pluralize/sdk';import { app } from '@/lib/pluralize';export function useCurrentUser() { const [user, setUser] = useState<TenantInfo | null | undefined>(undefined); useEffect(() => { // Fires on signup, login, logout, and refresh-token rotation. return app.auth.onChange(setUser); }, []); return { user, loading: user === undefined };}
undefined means we haven't checked yet; null means signed out. That tri-state
is what saves you from the "logged-in flash" where the UI briefly shows the signed-out
state before hydration finishes.
Use it from your root page:
// app/page.tsx'use client';import { useCurrentUser } from '@/lib/use-current-user';import { LoginPrompt } from '@/components/login-prompt';import { RecipeApp } from '@/components/recipe-app';export default function Home() { const { user, loading } = useCurrentUser(); if (loading) return <p className="p-6 text-sm text-muted-foreground">Loading…</p>; return user ? <RecipeApp /> : <LoginPrompt />;}
Step 7 — Refactor your hardcoded user out
This is the moment Maya has been bracing for. The good news: the SDK does the
isolation for her. She doesn't need a tenant_id column, doesn't need to write
WHERE user_id = ?, doesn't need to migrate her existing schema.
Before — her own table, her own SQL, hardcoded user:
// app/api/recipes/route.ts — DELETE THIS FILEimport { sql } from '@/lib/db';const HARDCODED_USER_ID = 1;// GET / POST handlers reading and writing recipes for user 1...
After — Pluralize's collection, no API route at all:
// components/recipe-app.tsx'use client';import { useEffect, useState } from 'react';import { app } from '@/lib/pluralize';import { PluralizeError, type DataRecord } from '@pluralize/sdk';type Recipe = { title: string; minutes: number };export function RecipeApp() { const [records, setRecords] = useState<DataRecord[]>([]); const [error, setError] = useState<string | null>(null); useEffect(() => { app.db.collection('recipes').find().then((res) => setRecords(res.records)); }, []); async function add(title: string, minutes: number) { try { await app.db.collection<Recipe>('recipes').insert({ title, minutes }); const { records } = await app.db.collection('recipes').find(); setRecords(records); } catch (err) { if (err instanceof PluralizeError && err.code === 'limit_reached') { setError('You hit your plan limit. Upgrade to add more.'); } } } return ( <main className="mx-auto max-w-xl p-6"> <ul className="divide-y rounded-md border"> {records.map((r) => ( <li key={r.id} className="flex justify-between px-3 py-2 text-sm"> <span>{(r.data as Recipe).title}</span> <span className="text-muted-foreground">{(r.data as Recipe).minutes} min</span> </li> ))} </ul> {error && <p className="mt-3 text-sm text-red-600">{error}</p>} </main> );}
Two things to notice:
There's no WHERE user_id = anywhere in this code. The SDK attaches the user's
token; the Pluralize backend filters rows at the database level using Postgres
row-level security.
There's no API route. app.db.collection(...) runs in the browser. If you
want an API route — for tier-aware caps, server-side validation, or because
you have your own database for some collections — see
Server-side integration.
What happens to your existing recipes? They stay in your old table. Pluralize
doesn't read them. The simplest migration is the one Maya does: stop writing to the
old table, write everything new through Pluralize, and accept that her own
recipes stay where they were until she decides to manually copy them over.
If you'd rather forklift your data, write a one-off script that calls
app.db.collection('recipes').insert(...) for each row while logged in as
the user that owns them.
Step 8 — Show the verify-email banner
Your users land in your app immediately after signup, even if they haven't verified
their email yet — which keeps the onboarding flow tight, but means you're on the hook
for nudging them. The SDK exposes the verification state on TenantInfo:
// components/verify-email-banner.tsx'use client';import { useState } from 'react';import { useCurrentUser } from '@/lib/use-current-user';import { app } from '@/lib/pluralize';export function VerifyEmailBanner() { const { user } = useCurrentUser(); const [sent, setSent] = useState(false); if (!user || user.emailVerifiedAt !== null) return null; async function resend() { await app.auth.resendVerification({ locale: 'en' }); setSent(true); } return ( <div className="border-b bg-amber-50 px-4 py-2 text-sm"> <span>Please verify your email — we sent a link to {user.email}.</span> <button onClick={resend} className="ml-3 underline"> {sent ? 'Sent ✓' : 'Resend'} </button> </div> );}
Mount it in your root layout above the children, and it disappears automatically
the first time a user clicks the verification link in their inbox. Gmail and
iCloud quietly delay verification mail by 30 to 60 seconds in some cases — set the
expectation in copy.
Step 9 — Define plans and gate a feature
Open Dashboard → Plans. The default app ships with one free plan. Add a Pro
tier:
Name: Pro
Slug: pro (immutable after creation)
Price: 900 cents = $9 / month
Currency: usd
Interval: month
Entitlements: add max_recipes (limit, value 500) and export_csv (boolean,
value true).
Save. The dashboard provisions a Stripe Product + Price for you (it'll prompt you
to connect Stripe the first time). Free plans skip Stripe entirely; you only need
to connect when you actually want to charge.
Read entitlements from your UI to gate features:
import { app } from '@/lib/pluralize';const canExport = await app.billing.hasFeature('export_csv'); // booleanconst cap = await app.billing.getLimit('max_recipes'); // number | null
The max_recipes cap is enforced automatically when you call
app.db.collection('recipes').insert(...): if the tenant's row count is at the
cap, the call rejects with PluralizeErrorcode: 'limit_reached'. That's why
the recipe-add handler in Step 7 catches that code — your job is just to surface
the upgrade message; the counting is done for you.
Stripe Checkout takes over from here. After payment, the user is redirected back to
successUrl with an active subscription; their entitlement bag updates immediately
on the next getSubscription / hasFeature call.
For "manage my subscription" buttons, point at the customer portal:
Pluralize uses Stripe Connect — money goes to your Stripe account, Pluralize takes a
flat 5% application fee. You don't write any webhook code; Pluralize keeps the
subscription record up to date for getSubscription and hasFeature.
Step 11 — Test the whole loop in dev
Before you deploy, prove the round-trip works locally:
npm run dev and open http://localhost:3000/signup.
Sign up with a real email (Pluralize sends a real verification message).
Add three recipes. Confirm /dashboard shows the new tenant and the row count
ticks up.
Sign out. Sign in. Confirm your recipes are still there.
In a different browser (or incognito), sign up as a second user. Confirm
you only see the second user's recipes — never the first user's.
Click your upgrade button. Use Stripe's test card 4242 4242 4242 4242,
any future expiry, any CVC. Confirm app.billing.getSubscription() now
returns the Pro plan.
Step 5 is the single most important verification you'll do. It's the test that
proves multi-tenancy is real and not a clever bug waiting to ship.
What can still go wrong
Reality has a way of surprising you. Here are the four problems that ate me alive
the first time I shipped a Pluralize app, in rough order of likelihood.
"Login works in dev but fails in production"
You forgot to add https://your-domain.com to the CORS allowlist. The dev origin
is whitelisted, the prod origin isn't, and the browser blocks the preflight before
the SDK even gets a chance to run. Fix: dashboard → API Keys → add the prod origin.
"I'm getting app_paused everywhere"
You haven't used the app for 30 days. Pluralize auto-pauses dormant apps to protect
you from a bot scripting traffic against a forgotten prototype. Reactivate from the
dashboard — one click.
"Limits are firing on the wrong collection"
max_<collection> is enforced on the exact string you pass to
app.db.collection(name). recipes and Recipes are two different collections,
and Pluralize will happily count rows separately. Pick a casing and stick to it.
"Tokens disappear when the user opens the app in a new tab"
This shouldn't happen — the SDK uses localStorage, which is per-origin and
shared across tabs. If you see it anyway, you probably initialized the SDK with
storage: ... pointing at sessionStorage (which is per-tab). Remove the
custom storage unless you actively need it.
Where to go from here
You've shipped the smallest viable multi-tenant SaaS. The rest of the docs cover
the parts you'll add next: