Skip to content

Example: SPA with API ​

A complete multi-worker stack with a Vue/Vite SPA and a backend API worker. This is the pattern used in production by DragonMastery apps. Includes ephemeral PR preview environments.

Project structure ​

my-app/
  tamer/
    project.config.ts
    workers/
      api/
        base.ts
        env/
          dev.ts
          prod.ts
      spa/
        base.ts
        env/
          dev.ts
          prod.ts
  workers/cf-app-api/       # API worker code
    src/worker.ts
    wrangler.json           # generated by tamer (gitignored)
    wrangler.vitest.json    # generated sibling for Vitest (gitignored)
  apps/cf-app-spa/          # SPA app code
    src/
    vite.config.ts
    dist/                   # built by tamer deploy (gitignored)
    wrangler.json           # generated by tamer (gitignored)
    wrangler.vitest.json    # generated sibling for Vitest (gitignored)
  package.json
  .env                      # CF credentials only (gitignored)

Project config ​

ts
// tamer/project.config.ts
import { defineConfig } from "@dragonmastery/tamer";
import { apiWorker } from "./workers/api/base";
import { spaWorker } from "./workers/spa/base";

export default defineConfig({
  stack: "my-app",
  account_id: "abc123",
  compatibility_date: "2025-05-19",

  workers: {
    api: apiWorker,
    spa: spaWorker,
  },

  outputs: {
    api_worker_name: "${tamer:worker:api.name}",
  },
});

Ephemeral PR-preview envs

This example deploys ephemeral pr-<n> envs (see "PR preview" below). For a stack that uses ephemeral envs but has no Workers-for-Platforms tenants, declare wfp with just the pattern — namespaces is optional:

ts
wfp: {
  ephemeralEnvPattern: "^pr-",
},

This enables ephemeral-env semantics (dispatch-namespace sharing, env.dev override fallback, tamer env gc eligibility) without provisioning tenant workers. See Environments.

API worker ​

Routes are declared once in the base config with the bare apex. Tamer's route expansion handles the {env}. prefix automatically — dev gets dev.api.myapp.com, pr-42 gets pr-42.api.myapp.com, prod gets the bare apex. No per-env route overrides needed.

ts
// tamer/workers/api/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { apiDevEnv } from "./env/dev";
import { apiProdEnv } from "./env/prod";

export const apiWorker = defineWorker({
  path: "workers/cf-app-api",
  scriptName: "my-app-api",
  main: "src/worker.ts",

  resources: {
    d1: [{ logicalName: "settings", type: "single", binding: "DB" }],
    r2: [{ logicalName: "uploads", binding: "R2" }],
  },

  secrets: {
    required: ["STRIPE_API_KEY", "JWT_SECRET"],
  },

  vars: {
    ENVIRONMENT: "local",
    WEBSITE_URL: "http://localhost:5993",
  },

  // One route declaration — expansion handles per-env hostnames:
  //   dev     → dev.api.myapp.com
  //   pr-42   → pr-42.api.myapp.com
  //   prod    → api.myapp.com (bare apex)
  tamerRoutes: [
    { host: "api.myapp.com", customDomain: true },
  ],

  env: {
    dev: apiDevEnv,
    prod: apiProdEnv,
  },
});
ts
// tamer/workers/api/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";

// ${tamer:env} resolves to the current env name — "dev" for dev deploys,
// "pr-42" for PR previews (ephemeral envs fall back to dev overrides).
export const apiDevEnv = {
  vars: {
    ENVIRONMENT: "${tamer:env}",
    WEBSITE_URL: "https://${tamer:env}.myapp.com",
  },
} satisfies EnvOverride;
ts
// tamer/workers/api/env/prod.ts
import type { EnvOverride } from "@dragonmastery/tamer";

export const apiProdEnv = {
  vars: {
    ENVIRONMENT: "prod",
    WEBSITE_URL: "https://myapp.com",
  },
} satisfies EnvOverride;

SPA worker ​

The SPA worker serves static assets and declares a build step so Tamer compiles the bundle per env with the correct API URL baked in.

ts
// tamer/workers/spa/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { spaDevEnv } from "./env/dev";
import { spaProdEnv } from "./env/prod";

export const spaWorker = defineWorker({
  path: "apps/cf-app-spa",
  scriptName: "my-app-web",
  assets: {
    directory: "dist",
    not_found_handling: "single-page-application",
  },

  build: { command: "vite build" },

  vars: {
    ENVIRONMENT: "local",
    VITE_API_CLIENT_URL: "http://127.0.0.1:8993/v1",
    VITE_APP_ENV: "local",
  },

  // Same expansion as the API worker:
  //   dev     → dev.myapp.com
  //   pr-42   → pr-42.myapp.com
  //   prod    → myapp.com
  tamerRoutes: [
    { host: "myapp.com", customDomain: true },
  ],

  env: {
    dev: spaDevEnv,
    prod: spaProdEnv,
  },
});
ts
// tamer/workers/spa/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";

export const spaDevEnv = {
  vars: {
    ENVIRONMENT: "${tamer:env}",
    // ${tamer:env} makes this work for dev AND ephemeral PR envs:
    //   dev   → https://dev.api.myapp.com/v1
    //   pr-42 → https://pr-42.api.myapp.com/v1
    VITE_API_CLIENT_URL: "https://${tamer:env}.api.myapp.com/v1",
    VITE_APP_ENV: "${tamer:env}",
  },
} satisfies EnvOverride;
ts
// tamer/workers/spa/env/prod.ts
import type { EnvOverride } from "@dragonmastery/tamer";

export const spaProdEnv = {
  vars: {
    ENVIRONMENT: "prod",
    VITE_API_CLIENT_URL: "https://api.myapp.com/v1",
    VITE_APP_ENV: "prod",
  },
} satisfies EnvOverride;

How ${tamer:env} + ephemeral fallback works ​

EnvVITE_API_CLIENT_URL resolves toHow
localhttp://127.0.0.1:8993/v1Base config (local override)
devhttps://dev.api.myapp.com/v1Dev override: ${tamer:env} → dev
pr-42https://pr-42.api.myapp.com/v1Ephemeral fallback to dev, ${tamer:env} → pr-42
prodhttps://api.myapp.com/v1Prod override (bare apex)

No per-PR config entries. The dev override block serves double duty for all non-prod, non-local envs.

Local workflow ​

No Cloudflare credentials. Generate, migrate, then run Wrangler / Vite yourself — Tamer does not spawn them:

bash
tamer wrangler --env local
tamer migrate --env local          # if the API declared migrationsDir
# API: wrangler dev --config workers/cf-app-api/wrangler.json
# SPA: vite dev (point VITE_* at http://127.0.0.1:8787)

Point Vitest at ./wrangler.vitest.json. Local secrets go in workers/cf-app-api/.dev.vars. Full loop: Local Development.

Deploy workflow ​

Dev / prod (CI) ​

bash
tamer bootstrap              # once per account
tamer apply --env dev        # provision D1, R2, KV
tamer deploy --env dev       # build SPA + deploy both workers + routes

PR preview (ephemeral) ​

bash
# No bootstrap needed — account is already bootstrapped.
# No per-PR config — ephemeral fallback + ${tamer:env} handles it.

# Copy dev's vault contents into the pr-42 namespace (ciphertext-only copy;
# no decryption, so dev's master key is required but pr-42's is not):
tamer secrets copy --from dev --to pr-42

tamer apply --env pr-42      # creates db_settings_pr-42, etc.
tamer deploy --env pr-42     # builds SPA with pr-42 API URL, deploys

# → live at pr-42.myapp.com
# → API at pr-42.api.myapp.com

# Cleanup when PR closes (pr-* are not protected by default — no
# --confirm-env / --force required; CI often still passes both):
tamer destroy --env pr-42 --wipe-metadata
# CI-style (harmless extras):
# tamer destroy --env pr-42 --confirm-env pr-42 --wipe-metadata --force

How deploy works per worker ​

Deploy runs per worker in topological order (API first if SPA has a service binding; otherwise order doesn't matter):

API worker (no build step):

  1. Push secrets from vault (STRIPE_API_KEY, JWT_SECRET)
  2. Generate wrangler.json
  3. wrangler types → wrangler deploy

SPA worker (has build step):

  1. Generate wrangler.json (with resolved vars, WITHOUT build field)
  2. Spawn vite build in apps/cf-app-spa/ with resolved vars as env:
    • VITE_API_CLIENT_URL=https://dev.api.myapp.com/v1 → baked into bundle
  3. wrangler types → wrangler deploy (uploads the pre-built dist/)

What does NOT happen ​

  • No .env file written to apps/cf-app-spa/
  • No wrangler.json read-back by a bridge script
  • No Wrangler custom build (the build field is stripped from wrangler.json)
  • No runtime config endpoint
  • No per-PR bootstrap or config entries

.env file ​

# .env (repo root, gitignored)
CLOUDFLARE_ACCOUNT_ID=abc123
CLOUDFLARE_API_TOKEN=your-token
TAMER_SECRETS_KEY_dev=your-vault-key

Just Tamer's auth credentials for remote commands. Not required for --env local. No VITE_* values, no app config.

Released under the Tamer Evaluation License.