App anatomy

What's inside a Spikefrost app: the project tree, the deployable Worker under app/, the bindings your code gets for free, the declaration files that provision infrastructure, and the build pipeline.

The Spikefrost Team30 Jul 20265 min read

A Spikefrost app is a normal TypeScript project with a specific shape. Learn the shape once and everything else in this section follows.

The tree

my-app/
├── .spikefrost/          Binding to the cloud app (project.json)
├── CLAUDE.md             Generated agent instructions — don't hand-edit
├── AGENTS.md             Same, for other coding agents
├── DECISIONS.md          Your decision log — versioned app content
├── app/                  ← THE DEPLOYABLE WORKER
│   ├── package.json      Scripts + deps for the Worker
│   ├── wrangler.toml     Local tooling only; not read on deploy
│   ├── tsconfig.json
│   ├── migrations/       SQL migrations, applied on deploy
│   │   └── 0001_i18n.sql
│   ├── src/
│   │   ├── index.tsx     Entry point — routes and middleware
│   │   ├── types/        Env bindings and AppType
│   │   ├── components/   JSX components
│   │   ├── utils/        Helpers, platform calls
│   │   ├── lib/          Database session middleware
│   │   ├── i18n/         Translation middleware
│   │   └── styles.css    Tailwind entry
│   └── dist/             Build output — never read or edit
└── mobile/               React Native companions, one folder per audience

Two rules follow from this layout:

  1. App code goes under app/. The project root is your workspace — data files, notes, research, working material. Only app/ is bundled and deployed, so anything at the root travels with the app's file history without ending up in the Worker.
  2. dist/ is output. Regenerated on every build. Never edit it, never read it to understand behavior.

Older apps may have their code at the root instead of under app/ (no app/package.json). Build and deploy detect either layout. Don't migrate an existing app between layouts unless you have a specific reason.

DECISIONS.md

An append-only log of why the app is built the way it is, seeded when the app is created. It's real app content — versioned, synced, copied on clone — so the next session, human or AI, inherits your reasoning.

Append an entry when you pick one approach over a real alternative or change a contract (API shape, schema meaning, auth model, payment flow). Not for routine work; the code and deploy history already cover that.

Bindings — what your code gets for free

The platform provisions infrastructure when the app is created and injects it at deploy. You declare the types in app/src/types/index.ts:

export interface Env {
  DB: D1Database;          // SQL database
  ASSETS: R2Bucket;        // public file storage
  KV: KVNamespace;         // key-value store
  SPIKE_TENANT_API_KEY?: string;   // platform capability auth — injected
  SPIKE_MCP_URL?: string;          // capability endpoint — injected
  SPIKE_PROJECT_ID?: string;       // this app's id — injected
  PUBLIC_URL?: string;
}

export type AppType = {
  Bindings: Env;
  Variables: { locale: string; t: TranslationFn };
};

Reach them through the request context — c.env.DB, c.env.KV — never process.env.

Things to know:

  • Add new bindings to Env; never redefine it. The generated shape is what the platform injects against.
  • SPIKE_* values are injected, not yours to set. They're server-side secrets. Never add them manually, never expose them to a browser. The tenant key can reach every integration your team has configured — treat it accordingly.
  • ANALYTICS appears too if you've enabled per-app metrics. It attaches on the next deploy after enabling, so it doesn't exist until then.
  • Each environment has its own database, KV, and storage. Staging never touches production data.

Declaration files — infrastructure as app content

Some infrastructure is declared in files and reconciled on deploy, not clicked into existence:

File Declares
app/migrations/NNNN_*.sql Database schema changes (Database)
app/durable-objects.json Durable Object classes (Durable Objects)
app/queues.json Managed queues and their consumers
app/search.json Managed AI Search instances
app/agents/** The app's agents — versioned and deployed like any other code
agent-config/ Declared routines and agent configuration, per environment

wrangler.toml is not one of these. It exists so local tooling works, but the platform does not read it when deploying. Adding a [[durable_objects]] section there does nothing — declare the class in app/durable-objects.json instead. This trips people up exactly once.

The build pipeline

app/package.json has two scripts that matter:

{
  "scripts": {
    "prebuild": "node scripts/build-content.mjs && tsc --noEmit && sh prebuild-check.sh",
    "build": "tailwindcss -i src/styles.css -o dist/worker.css --minify && esbuild src/index.tsx --bundle --outdir=dist --entry-names=worker --format=esm --target=es2022 --jsx=automatic --jsx-import-source=hono/jsx --minify"
  }
}

prebuild runs first: any content compilation the app needs, a full typecheck, and a platform check script that fails the build on patterns which break at runtime. Then build compiles Tailwind and bundles the Worker with esbuild.

Run the whole thing with sf build. See Build and deploy.

What the build rejects

The checks exist because these mistakes produce a broken deployed site rather than a compile error. Apps are Hono JSX, server-side rendered — it looks like React and isn't:

Rejected Use instead
className, htmlFor class, for
strokeWidth, strokeLinecap stroke-width, stroke-linecap
useState, useEffect, any hook Nothing — there is no client-side React
dangerouslySetInnerHTML {raw('...')} from hono/html
import ... from 'react' | 'express' | 'next' Hono
c.req.body, c.req.cookie, c.req.params.x c.req.json(), getCookie(c, 'x'), c.req.param('x')

Also enforced by convention, and worth internalizing:

  • Numeric attributes take numbers: rows={4}, not rows="4".
  • Inline scripts and styles need raw() from hono/html, wrapping a template literal: <script>{raw(...)}</script>.
  • Never interpolate into raw(). Pass data via data-* attributes and read it back with document.querySelector('[data-x]').dataset.x inside the script. String interpolation into a script tag is an injection hole.
  • No inline event handlers (onclick, onsubmit). Use data-* attributes and event delegation inside a raw() script.
  • No app.listen, no process.env. Neither exists on Workers.

Next

  • Routing and pages — how requests become HTML, and the one ordering rule that matters.
  • Database — schema, migrations, and the performance rules that keep a site up.
  • Files and assets — where media, data, and generated files belong.

Frequently asked questions

Why is the app code under app/ instead of at the root?

The project root is your workspace — notes, data files, working material. Only app/ is bundled and deployed. Keeping them separate means you can store whatever you like alongside an app without shipping it.

Do I need to configure wrangler.toml?

No. It exists for local tooling, but the platform does not read it on deploy. Bindings and Durable Objects are provisioned from platform state and the app's declaration files, so editing wrangler.toml has no effect on what ships.

Can I use React, Next.js, or Express?

No — apps are Hono on Cloudflare Workers, and the build actively rejects React hooks, Express request patterns, and framework imports. This is server-side rendered JSX, not React.

Where do database bindings come from?

The platform provisions the database, KV namespace, and R2 bucket when the app is created and injects them into the Worker at deploy time. You declare their types in Env and use them via c.env.

Is dist/ safe to edit?

No. It's build output, regenerated on every build. Never read it for truth or edit it by hand.