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)
  apps/cf-app-spa/          # SPA app code
    src/
    vite.config.ts
    dist/                   # built by tamer deploy (gitignored)
    wrangler.json           # generated by tamer (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.

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:
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 typeswrangler 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 typeswrangler 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. No VITE_* values, no app config.

Released under the Tamer Evaluation License.