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.