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.

Inside the generated workspace, 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. 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.

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. 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();

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 dev or semitra codegen apps/api.
  8. Use the generated frontend Task model from apps/web/app/models/task.ts.
  9. Build React Router routes that call Task.all(), Task.find(id), Task.create(attributes), or instance helpers such as task.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.

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