Skip to content

Full-Stack Apps

Semitra is a full-stack framework with an opinionated split:

  • apps/api is the Cloudflare Worker JSON backend.
  • apps/web is 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.

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.ts

The 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:

Terminal window
bun run dev

The root script calls semitra dev, which codegens apps/api, runs the Worker dev server, and starts the React Router dev server for apps/web.

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 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.

For a feature such as tasks, make the contract visible on both sides:

  1. Add a D1 migration under apps/api/db/migrate.
  2. Add app/models/task.ts with a schema-backed Task record.
  3. Add app/resources/task_resource.ts for the public JSON fields.
  4. Add app/policies/task_policy.ts for authorization and scopes.
  5. Add app/controllers/api/v1/tasks_controller.ts.
  6. Add resources("tasks") inside the backend api() route block.
  7. Run semitra codegen apps/api.
  8. Add a frontend model under apps/web/app/models/task.ts.
  9. Add typed endpoints to apps/web/app/lib/api.ts.
  10. 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.

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(/\/$/, "");

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