Migrate a single-user app to multi-tenant

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/cli
pluralize login          # interactive
pluralize init --yes     # creates app + writes .env.local + CORS + snippets
pluralize 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/page.tsx — Maya's "before"
'use client';
import { useEffect, useState } from 'react';
 
type Recipe = { id: string; title: string; minutes: number };
 
export default function Home() {
  const [recipes, setRecipes] = useState<Recipe[]>([]);
 
  useEffect(() => {
    fetch('/api/recipes').then((r) => r.json()).then((d) => setRecipes(d.recipes));
  }, []);
 
  async function add(title: string, minutes: number) {
    await fetch('/api/recipes', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ title, minutes }),
    });
    // refetch...
  }
 
  return <RecipeList recipes={recipes} onAdd={add} />;
}
// app/api/recipes/route.ts — Maya's "before"
import { sql } from '@/lib/db';
 
const HARDCODED_USER_ID = 1; // 😬 the seed of all evil
 
export 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.

Step 1 — Create your Pluralize account

Open pluralize.app/signup in a new tab.

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:

# .env.local
NEXT_PUBLIC_PLURALIZE_APP_ID=app_xxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_PLURALIZE_API_KEY=pk_live_xxxxxxxxxxxxxxxxxxxx

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:

// lib/pluralize.ts
import { Pluralize } from '@pluralize/sdk';
 
export const app = Pluralize.init({
  appId: process.env.NEXT_PUBLIC_PLURALIZE_APP_ID!,
  apiKey: process.env.NEXT_PUBLIC_PLURALIZE_API_KEY!,
});

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.

// app/signup/page.tsx
'use client';
import { PluralizeSignup } from '@pluralize/sdk/react';
import { useRouter } from 'next/navigation';
import { app } from '@/lib/pluralize';
 
export default function SignupPage() {
  const router = useRouter();
  return (
    <main className="mx-auto max-w-sm p-6">
      <PluralizeSignup
        app={app}
        onSuccess={() => router.push('/')}
        onSignIn={() => router.push('/login')}
      />
    </main>
  );
}

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.

// app/signup/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { app } from '@/lib/pluralize';
import { PluralizeError } from '@pluralize/sdk';
 
export default function SignupPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);
 
  async function onSubmit(event: React.FormEvent) {
    event.preventDefault();
    setError(null);
    setSubmitting(true);
    try {
      await app.auth.signup(email, password, { locale: 'en' });
      router.push('/');
    } catch (err) {
      if (err instanceof PluralizeError) {
        if (err.code === 'duplicate_email') setError('That email is already taken.');
        else if (err.code === 'weak_password') setError('Use at least 10 characters.');
        else setError('Something went wrong. Try again.');
      }
    } finally {
      setSubmitting(false);
    }
  }
 
  return (
    <form onSubmit={onSubmit} className="mx-auto max-w-sm space-y-3 p-6">
      <h1 className="text-lg font-semibold">Create your Recipe Box</h1>
      <input
        type="email"
        placeholder="you@example.com"
        required
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        className="w-full rounded-md border px-3 py-2"
      />
      <input
        type="password"
        placeholder="Password (10+ characters)"
        required
        minLength={10}
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        className="w-full rounded-md border px-3 py-2"
      />
      {error && <p className="text-sm text-red-600">{error}</p>}
      <button
        type="submit"
        disabled={submitting}
        className="w-full rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        {submitting ? 'Creating…' : 'Sign up'}
      </button>
    </form>
  );
}

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 FILE
import { 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:

  1. 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.
  2. 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'); // boolean
const 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 PluralizeError code: '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.

Step 10 — Send users to checkout

// components/upgrade-button.tsx
'use client';
import { app } from '@/lib/pluralize';
 
export function UpgradeButton({ planId }: { planId: string }) {
  async function onClick() {
    const { url } = await app.billing.createCheckout({
      planId,
      successUrl: `${window.location.origin}/welcome`,
      cancelUrl: `${window.location.origin}/pricing`,
    });
    window.location.href = url;
  }
  return (
    <button onClick={onClick} className="rounded-md bg-black px-4 py-2 text-white">
      Upgrade to Pro
    </button>
  );
}

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:

const { url } = await app.billing.createPortal({
  returnUrl: `${window.location.origin}/account`,
});
window.location.href = url;

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:

  1. npm run dev and open http://localhost:3000/signup.
  2. Sign up with a real email (Pluralize sends a real verification message).
  3. Add three recipes. Confirm /dashboard shows the new tenant and the row count ticks up.
  4. Sign out. Sign in. Confirm your recipes are still there.
  5. 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.
  6. 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:

  • Authentication — login, password reset, invite-only signup, viral invite tokens.
  • Data — filters, sort, pagination, unique fields, share links.
  • Files — per-user uploads with MIME and size guardrails.
  • Billing — entitlements, upsells, customer portal.
  • Server-side integration — verify tokens in your own API routes when you have backend logic Pluralize doesn't host.
  • Pricing — what Pluralize charges you, platform caps, dormant-app behavior.

When you ship to your first ten paying users, send me an email — I want to know.