Full-Stack Apps
Semitra is a full-stack framework with an opinionated split:
apps/apiis the Cloudflare Worker JSON backend.apps/webis the React Router 7, Vite, and Tailwind frontend.
The backend owns routing, validation, authorization, persistence, side effects, and response contracts. The frontend consumes those contracts through typed models and a centralized API client.
Generated workspace shape
Section titled “Generated workspace shape”semitra new [name] creates a workspace with this shape:
apps/ api/ app/ controllers/ models/ resources/ policies/ jobs/ mailers/ mailboxes/ channels/ config/ application.ts routes.ts db/ migrate/ schema.ts src/ index.ts web/ app/ lib/ api.ts use-health-check.ts models/ health.ts routes/ home.tsx health.tsx root.tsx routes.ts vite.config.ts react-router.config.tsThe generated API uses /api/v1 as the canonical versioned API surface. The
generated web app starts with a health endpoint so the frontend can prove it is
talking to the Worker.
Inside the generated workspace, semitra dev starts both sides together:
bun run devThe root script calls semitra dev, which codegens apps/api, runs the Worker
dev server, and starts the React Router dev server for apps/web. The same
default applies when semitra dev is run from apps/api; use
semitra dev --api-only only when you intentionally want the Worker alone.
Backend contract
Section titled “Backend contract”Routes live in apps/api/config/routes.ts:
import { api, drawRoutes, get } from "@semitra/cli";
export default drawRoutes(() => { api(() => { get("health", "health#show"); });});The api() helper applies the default JSON API shape. By default, that means a
route such as get("health", "health#show") is exposed at /api/v1/health.
Controllers validate input, authorize work, and render resources:
const input = this.params.require(CreatePostParams);const Posts = this.model(Post);
await this.authorize(Post, "create");const post = Posts.build(input.post);
if (!(await post.save())) { return this.renderErrors(post.errors);}
return this.renderResource(post, { status: 201 });The controller does not hand-build response JSON when a resource exists. Resources are the public response contract.
Frontend contract
Section titled “Frontend contract”Frontend models live under apps/web/app/models. Semitra generates resource
model contracts for backend models/resources in full-stack workspaces. Generated
model implementations live under apps/web/app/models/generated, with stable
exports in apps/web/app/models.
The generated health model shows the lower-level schema pattern:
import { defineSemitraModel, s, type Infer } from "@semitra/cli/frontend";
export const HealthCheck = defineSemitraModel( "HealthCheck", s.object({ ok: s.boolean(), project: s.string(), service: s.string(), checkedAt: s.datetime() }));
export type HealthCheckResponse = Infer<typeof HealthCheck.schema>;The API client lives in apps/web/app/lib/api.ts:
import { createSemitraApiClient, defineSemitraEndpoint} from "@semitra/cli/frontend";import { HealthCheck } from "../models/health.ts";
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?.replace(/\/$/, "");
export const api = createSemitraApiClient({ baseUrl: apiBaseUrl, endpoints: { health: defineSemitraEndpoint({ method: "GET", path: "/api/v1/health", response: HealthCheck.schema }) }});Frontend route modules call this client or generated model helpers instead of
scattering fetch() calls and unchecked response shapes throughout the UI.
For a generated Post model, frontend code can use API-backed helpers:
const posts = await Post.all();const post = await Post.find(1);const created = await Post.create({ title: "Hello" });await created.update({ title: "Updated" });await created.destroy();Add a new feature end to end
Section titled “Add a new feature end to end”For a feature such as tasks, make the contract visible on both sides:
- Add a D1 migration under
apps/api/db/migrate. - Add
app/models/task.tswith a schema-backedTaskrecord. - Add
app/resources/task_resource.tsfor the public JSON fields. - Add
app/policies/task_policy.tsfor authorization and scopes. - Add
app/controllers/api/v1/tasks_controller.ts. - Add
resources("tasks")inside the backendapi()route block. - Run
semitra devorsemitra codegen apps/api. - Use the generated frontend
Taskmodel fromapps/web/app/models/task.ts. - Build React Router routes that call
Task.all(),Task.find(id),Task.create(attributes), or instance helpers such astask.update(...).
That is the main Semitra full-stack loop: backend schema and resource contracts
stay authoritative, and Semitra generates matching frontend models that parse
responses and call the API. semitra dev checks for frontend model drift and
updates generated model files before starting the API and web dev servers.
Environment handoff
Section titled “Environment handoff”The generated Vite dev server proxies /api to the local Worker. semitra dev
sets SEMITRA_API_ORIGIN for the web process when the API host or port changes,
so the browser can keep using relative /api requests during local development.
Use VITE_API_BASE_URL when the web app needs to call an API origin that is
different from the frontend origin. Leave it unset when the frontend and API are
served from the same origin.
The generated API client trims a trailing slash so endpoint paths can stay consistent:
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?.replace(/\/$/, "");Full-stack discipline
Section titled “Full-stack discipline”Keep responsibilities narrow:
- define untrusted input schemas at the boundary
- keep authorization in policies
- keep persistence in records
- keep response shape in resources
- keep frontend API access in
app/lib/api.ts - keep frontend validation in models under
app/models - do not duplicate backend persistence rules in the frontend