Skip to content

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:

TierRoleExample
Tier 0 (Platform)Manages global settings, protected tokens, platform configRecipeSyncUp 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 communityA 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

bash
/webplatform4sync:site scaffold

Tell 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.md

Key files to check:

jsonc
// wrangler.jsonc
{
  "name": "recipesyncup-com",
  "compatibility_date": "2025-12-01",
  "main": "src/worker.ts",
  "hyperdrive": [{
    "binding": "DB",
    "id": "<your-hyperdrive-id>"
  }]
}
bash
# Verify the dev server starts
doppler run -- pnpm dev

You 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:

bash
/webplatform4sync:site envrc

This 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

bash
/webplatform4sync:pour database

Tell 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

sql
-- 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:

sql
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:

typescript
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:

bash
doppler run -- pnpm drizzle-kit push

You should see output confirming the tables were created. Verify with:

bash
doppler run -- pnpm drizzle-kit studio

Drizzle 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

bash
/webplatform4sync:frame auth

Tell 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 collections

What 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

LevelCan doIdentity
ANONYMOUSBrowse published recipes, view collectionsNone
PREVIEWSave recipe bookmarks (KV-backed)Session cookie
OAUTHCreate recipes, join communitiesGoogle via Firebase
FULLManage communities, create collections, adminBetter Auth session

The middleware enforces these levels per route:

typescript
// 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

bash
/webplatform4sync:frame worker

Tell Claude:

I need API routes for:
- List published recipes (public)
- CRUD recipes (authenticated)
- List/manage collections (authenticated)
- Tenant resolution from domain

What 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:

typescript
// 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

typescript
// 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:

bash
doppler run -- wrangler dev

The Worker starts locally. Test the public endpoint:

bash
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

bash
/webplatform4sync:wire ci

Tell 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 Doppler

What happens

The skill creates a GitHub Actions workflow, configures Doppler service tokens as GitHub secrets, and sets up the deploy pipeline.

CI configuration

yaml
# .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

bash
/webplatform4sync:wire secrets

This configures Doppler to sync secrets into the environments that need them:

SecretWhere it livesUsed by
NEON_DATABASE_URLDoppler recipesyncup-comWorker, Drizzle CLI
BETTER_AUTH_SECRETDoppler recipesyncup-comWorker
VITE_OAUTH_PUBLIC_GOOGLE_CLIENT_IDDoppler recipesyncup-comClient-side OAuth
OAUTH_GOOGLE_CLIENT_SECRETDoppler recipesyncup-comWorker
CLOUDFLARE_API_TOKENGitHub secretsCI deploy step

Expected output

Push to main and watch the Actions tab:

bash
git add -A && git commit -m "initial recipesyncup setup" && git push

The 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

bash
/webplatform4sync:finish theme

Tell 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

bash
pnpm add @syncupsuite/themes

In your main CSS file:

css
/* 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

tsx
// 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

bash
/webplatform4sync:finish a11y

This 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

bash
doppler run -- pnpm dev

The 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 tenants

Next Steps

Released under the MIT License.