Build a Multi-Tenant App with the Construction Frame
Walk through the full Construction frame sequence -- site, pour, frame, wire, finish -- using RecipeSyncUp as the example product. By the end, you have a deployed multi-tenant recipe sharing platform with tenant isolation, graduated auth, CI/CD, and a cultural design identity.
The Construction frame enforces a build order. Each stage gates the next because you cannot wire a house before you frame it. This tutorial follows that order exactly.
Prerequisites
- Node.js 22+, pnpm 9.15+
- Claude Code with the marketplace plugin — add to
.claude/settings.json:json{ "permissions": { "extraKnownMarketplaces": { "webplatform4sync": { "source": { "source": "github", "repo": "syncupsuite/webplatform4sync" } } } } } - A Cloudflare account (free tier works)
- A Neon database (free tier works)
- A Doppler account for secrets management
What You'll Build
RecipeSyncUp -- a multi-tenant recipe sharing platform where:
| Tier | Role | Example |
|---|---|---|
| Tier 0 (Platform) | Manages global settings, protected tokens, platform config | RecipeSyncUp admin |
| Tier 1 (Partner) | Runs a branded recipe community, manages their own users | "Nordic Kitchen" community |
| Tier 2 (Customer) | Individual recipe creator within a partner's community | A home cook publishing recipes |
One database. One Worker deployment. Unlimited tenants.
Stage 1: Site -- Initialize the Project
Clear the ground. Set up the project environment, tooling, and local dev configuration.
Run the command
/webplatform4sync:site scaffoldTell Claude what you are building:
I'm building RecipeSyncUp, a multi-tenant recipe sharing platform.
Domain: recipesyncup.com
Stack: Vite + React + TypeScript, Cloudflare Workers, Neon PostgreSQL.What happens
The skill reads your intent and scaffolds a Vite + React + TypeScript project with the SyncupSuite standard stack. It creates the project structure, configures wrangler.jsonc for Cloudflare Workers, sets up TypeScript strict mode, and wires Doppler for secrets.
Expected output
A working dev server and this directory structure:
recipesyncup-com/
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── components/
│ ├── styles/
│ │ └── app.css
│ └── lib/
├── public/
├── drizzle/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── wrangler.jsonc
├── .envrc
└── CLAUDE.mdKey files to check:
// wrangler.jsonc
{
"name": "recipesyncup-com",
"compatibility_date": "2025-12-01",
"main": "src/worker.ts",
"hyperdrive": [{
"binding": "DB",
"id": "<your-hyperdrive-id>"
}]
}# Verify the dev server starts
doppler run -- pnpm devYou should see the Vite dev server at http://localhost:5173 with a blank React app.
Set up the environment
Run the environment config skill to wire Doppler and .envrc:
/webplatform4sync:site envrcThis auto-detects your Cloudflare account and creates an .envrc that loads secrets from Doppler on cd.
Stage 2: Pour -- Foundation and Data Layer
Foundation in. Create the database schema with tenant isolation baked into every table.
Run the command
/webplatform4sync:pour databaseTell Claude about your domain:
RecipeSyncUp needs these entities:
- Organizations (the tenant -- a recipe community)
- Recipes (title, description, ingredients, instructions, cook time)
- Recipe collections (curated groups of recipes within a community)
Users belong to organizations. Recipes belong to organizations. Collections group recipes.What happens
The skill creates a Neon schema with tenant_id on every tenant-scoped table, sets up Drizzle ORM with typed schemas, and enables Row-Level Security so the database enforces isolation -- not application code.
Schema design
-- Tenant table: one row per community
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
tier INTEGER NOT NULL DEFAULT 2,
parent_id UUID REFERENCES organizations(id),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Recipes: scoped to a tenant
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES organizations(id),
author_id UUID NOT NULL,
title TEXT NOT NULL,
description TEXT,
ingredients JSONB NOT NULL DEFAULT '[]',
instructions TEXT NOT NULL,
cook_time_minutes INTEGER,
published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Collections: scoped to a tenant
CREATE TABLE recipe_collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Join table: recipes in collections
CREATE TABLE collection_recipes (
collection_id UUID NOT NULL REFERENCES recipe_collections(id),
recipe_id UUID NOT NULL REFERENCES recipes(id),
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (collection_id, recipe_id)
);RLS policies
Every tenant-scoped table gets the same pattern -- the database enforces isolation:
ALTER TABLE recipes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON recipes
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE recipe_collections ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON recipe_collections
USING (tenant_id = current_setting('app.tenant_id')::uuid);Even if application code forgets a WHERE tenant_id = ? clause, the query returns zero rows instead of leaking data. RLS is the security boundary.
Drizzle schema
The corresponding TypeScript schema in src/db/schema.ts:
import { pgTable, uuid, text, integer, boolean, jsonb, timestamptz, primaryKey } from "drizzle-orm/pg-core";
export const organizations = pgTable("organizations", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").unique().notNull(),
tier: integer("tier").notNull().default(2),
parentId: uuid("parent_id").references(() => organizations.id),
createdAt: timestamptz("created_at").defaultNow(),
});
export const recipes = pgTable("recipes", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => organizations.id),
authorId: uuid("author_id").notNull(),
title: text("title").notNull(),
description: text("description"),
ingredients: jsonb("ingredients").notNull().default([]),
instructions: text("instructions").notNull(),
cookTimeMinutes: integer("cook_time_minutes"),
published: boolean("published").default(false),
createdAt: timestamptz("created_at").defaultNow(),
updatedAt: timestamptz("updated_at").defaultNow(),
});
export const recipeCollections = pgTable("recipe_collections", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => organizations.id),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamptz("created_at").defaultNow(),
});
export const collectionRecipes = pgTable("collection_recipes", {
collectionId: uuid("collection_id").notNull().references(() => recipeCollections.id),
recipeId: uuid("recipe_id").notNull().references(() => recipes.id),
position: integer("position").notNull().default(0),
}, (t) => [primaryKey({ columns: [t.collectionId, t.recipeId] })]);Expected output
Push the schema to your Neon database:
doppler run -- pnpm drizzle-kit pushYou should see output confirming the tables were created. Verify with:
doppler run -- pnpm drizzle-kit studioDrizzle Studio opens at https://local.drizzle.studio showing your four tables.
Stage 3: Frame -- Auth and API
Skeleton up. Add authentication and API routes on the Cloudflare Worker.
Set up auth
/webplatform4sync:frame authTell Claude:
RecipeSyncUp auth levels:
- Anonymous users can browse published recipes
- OAuth users (Google) can create and save recipes
- Full account users can manage communities and collectionsWhat happens
The skill sets up Better Auth with Firebase Identity, wires the four auth levels into Worker middleware, and creates the graduation flow.
Auth levels for RecipeSyncUp
| Level | Can do | Identity |
|---|---|---|
| ANONYMOUS | Browse published recipes, view collections | None |
| PREVIEW | Save recipe bookmarks (KV-backed) | Session cookie |
| OAUTH | Create recipes, join communities | Google via Firebase |
| FULL | Manage communities, create collections, admin | Better Auth session |
The middleware enforces these levels per route:
// src/routes.ts
app.get("/api/recipes/public", noAuth());
app.get("/api/recipes/bookmarks", requireAuth(AuthLevel.PREVIEW));
app.post("/api/recipes", requireAuth(AuthLevel.OAUTH));
app.get("/api/admin/*", requireAuth(AuthLevel.FULL, { role: "admin" }));Each level includes all levels below it. A FULL user automatically passes OAUTH and PREVIEW checks.
Set up Worker routes
/webplatform4sync:frame workerTell Claude:
I need API routes for:
- List published recipes (public)
- CRUD recipes (authenticated)
- List/manage collections (authenticated)
- Tenant resolution from domainWhat happens
The skill creates a Cloudflare Worker with Hono (or your preferred router), wires Hyperdrive for database connections, and sets up tenant resolution.
Tenant resolution
The Worker resolves which community a request belongs to by inspecting the domain:
// src/middleware/tenant.ts
export async function resolveTenant(c: Context) {
const host = new URL(c.req.url).hostname;
const tenant = await c.get("db").query.domainMappings.findFirst({
where: eq(domainMappings.domain, host),
});
if (!tenant) {
return c.json({ error: "Unknown tenant" }, 404);
}
// Set RLS context -- the database now filters automatically
await c.get("db").execute(
sql`SELECT set_config('app.tenant_id', ${tenant.organizationId}, true)`
);
}Worker routes
// src/worker.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/neon-http";
import { resolveTenant } from "./middleware/tenant";
const app = new Hono<{ Bindings: Env }>();
// Tenant resolution runs on every request
app.use("*", resolveTenant);
// Public: browse recipes
app.get("/api/recipes", async (c) => {
const db = drizzle(c.env.DB.connectionString);
const results = await db.query.recipes.findMany({
where: eq(recipes.published, true),
orderBy: desc(recipes.createdAt),
});
return c.json(results);
});
// Authenticated: create a recipe
app.post("/api/recipes", requireAuth(AuthLevel.OAUTH), async (c) => {
const db = drizzle(c.env.DB.connectionString);
const body = await c.req.json();
const recipe = await db.insert(recipes).values({
...body,
tenantId: c.get("tenantId"),
authorId: c.get("userId"),
}).returning();
return c.json(recipe[0], 201);
});
export default app;Expected output
Deploy the Worker and verify:
doppler run -- wrangler devThe Worker starts locally. Test the public endpoint:
curl http://localhost:8787/api/recipes
# Returns: [] (empty array -- no recipes yet)Auth is wired. The public endpoint works without credentials. The create endpoint returns 401 without a valid session.
Stage 4: Wire -- CI/CD and Integrations
Systems connected. Set up automated deployment so pushes to main go live.
Run the command
/webplatform4sync:wire ciTell Claude:
Set up GitHub Actions CI/CD:
- Type-check, lint, and test on every PR
- Deploy to Cloudflare Workers on push to main
- Secrets from DopplerWhat happens
The skill creates a GitHub Actions workflow, configures Doppler service tokens as GitHub secrets, and sets up the deploy pipeline.
CI configuration
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm type-check
- run: pnpm lint
- run: pnpm test
deploy:
needs: check
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.15.4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}Wire secrets
/webplatform4sync:wire secretsThis configures Doppler to sync secrets into the environments that need them:
| Secret | Where it lives | Used by |
|---|---|---|
NEON_DATABASE_URL | Doppler recipesyncup-com | Worker, Drizzle CLI |
BETTER_AUTH_SECRET | Doppler recipesyncup-com | Worker |
VITE_OAUTH_PUBLIC_GOOGLE_CLIENT_ID | Doppler recipesyncup-com | Client-side OAuth |
OAUTH_GOOGLE_CLIENT_SECRET | Doppler recipesyncup-com | Worker |
CLOUDFLARE_API_TOKEN | GitHub secrets | CI deploy step |
Expected output
Push to main and watch the Actions tab:
git add -A && git commit -m "initial recipesyncup setup" && git pushThe workflow runs type-check, lint, and test. On success, it deploys the Worker to Cloudflare. You should see a green checkmark and a deploy URL in the Actions log.
Stage 5: Finish -- Design and Polish
Livable. Apply a cultural design identity and run accessibility checks.
Run the command
/webplatform4sync:finish themeTell Claude:
Apply the nihon-traditional theme to RecipeSyncUp.
It's a recipe platform -- the Japanese aesthetic fits the food/craft focus.What happens
The skill installs @syncupsuite/themes, imports the nihon-traditional identity, creates semantic aliases, and wires Tailwind v4 token integration.
Theme integration
pnpm add @syncupsuite/themesIn your main CSS file:
/* src/styles/app.css */
@import "tailwindcss";
@import "@syncupsuite/themes/nihon-traditional/tailwind.css";
@theme {
--color-primary: var(--color-primary-600);
--color-background: var(--color-neutral-50);
--color-foreground: var(--color-neutral-900);
}
/* Semantic aliases for RecipeSyncUp */
:root {
--rsu-background: var(--background-canvas);
--rsu-text: var(--text-primary);
--rsu-text-muted: var(--text-secondary);
--rsu-border: var(--border-default);
--rsu-accent: var(--interactive-primary);
--rsu-accent-hover: var(--interactive-primary-hover);
}The nihon-traditional theme brings colors grounded in Japanese calligraphy, woodblock printing, and temple architecture. Deep ink blacks, warm paper tones, and vermillion accents.
Use tokens in components
// src/components/RecipeCard.tsx
export function RecipeCard({ recipe }: { recipe: Recipe }) {
return (
<article class="bg-surface border border-border rounded-lg p-6">
<h2 class="text-foreground font-heading text-xl mb-2">
{recipe.title}
</h2>
<p class="text-foreground-secondary mb-4">
{recipe.description}
</p>
<div class="flex items-center gap-2 text-foreground-muted text-sm">
<span>{recipe.cookTimeMinutes} min</span>
<span class="text-border">|</span>
<span>{recipe.ingredients.length} ingredients</span>
</div>
</article>
);
}Swap nihon-traditional for any other theme slug and the entire palette changes. The component markup stays identical.
Accessibility audit
/webplatform4sync:finish a11yThis runs a WCAG audit against your themed components, checking contrast ratios, focus indicators, and semantic HTML. The nihon-traditional theme ships with WCAG AA-compliant contrast pairs, so most checks pass out of the box.
Expected output
doppler run -- pnpm devThe dev server shows RecipeSyncUp with the nihon-traditional identity applied -- warm neutrals, ink-dark text, vermillion accents. Dark mode works by adding data-theme="dark" to the <html> element.
What You've Built
A multi-tenant recipe sharing platform with:
- Tenant isolation at the database level. RLS policies enforce it. Application code cannot leak cross-tenant data even with a bug.
- Graduated authentication. Anonymous browsing, OAuth recipe creation, full account community management. No forcing login on first visit.
- One Worker, unlimited tenants. Domain mapping determines which community a request belongs to. Adding tenant #100 costs the same as tenant #10.
- Automated deployment. Push to main, CI runs checks, Worker deploys to Cloudflare.
- Cultural design identity. Not a generic color palette -- a design language rooted in a real tradition, applied through semantic tokens.
Architecture overview
recipesyncup.com
|
Cloudflare Worker (single deployment)
|
Tenant Resolution
/ | \
Tier 0 Tier 1 Tier 2
Platform Partners Customers
(admin panel) (Nordic Kitchen) (home cooks)
|
Hyperdrive Pool
|
Neon PostgreSQL (RLS)
one database
all tenantsNext Steps
- Adopting a Theme -- swap nihon-traditional for any of the 12 identities
- Auth Graduation -- deeper patterns for session management and account linking
- Multi-Tenant Architecture -- the full data isolation and governance model
- Token Governance -- how protected tokens prevent partners from breaking accessibility
- Skills Reference -- every Construction and Shu-Ha-Ri command mapped