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.
From the generated workspace root, 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.
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. The generated health model
shows the 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 instead of scattering fetch() calls
and unchecked response shapes throughout the UI.
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 codegen apps/api. - Add a frontend model under
apps/web/app/models/task.ts. - Add typed endpoints to
apps/web/app/lib/api.ts. - Build React Router routes that call
api.tasksIndex(),api.tasksShow(), or the endpoint names you define.
That is the main Semitra full-stack loop: backend schema and resource contracts stay authoritative, and the frontend parses responses through matching schema models.
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