The API Client
How the frontend consumes the backend REST API through the generated chowbea-axios client and the Result pattern.
The school website talks to two completely different data sources, and it's worth keeping them straight from day one. CMS content — the words and images editors manage — comes from Sanity. Application data — students, schools, form submissions, anything dynamic the backend owns — comes from a NestJS-style REST API at https://tunnel.chowbea.com, the "Naalya Schools API". This page is about that second one, and the small piece of machinery that makes it pleasant to use.
Here's the core idea: you never hand-write the types or methods for that API. The backend publishes a live OpenAPI (Swagger) spec, and a CLI called chowbea-axios reads it and generates a fully type-safe axios client into app/services/api/. New endpoint on the backend? Regenerate, and your editor immediately knows its path, its params, and the exact shape of what comes back. The types are a mirror of the backend, not a guess you maintain by hand.
Two clients, two jobs — don't cross the streams
app/lib/sanity-client.ts reads CMS content with GROQ. app/services/api/ is the REST client for backend application data. They share nothing. If you're fetching a blog post or a hero section, that's Sanity (Querying with GROQ). If you're fetching a student record or submitting an admissions form, that's this client.
The mental model: spec in, typed client out
The whole workflow is one loop. The backend serves a spec at https://tunnel.chowbea.com/docs/swagger/json. The CLI fetches it, hashes the body against a local cache, and — only if the hash changed — regenerates the client. That hashing is the quiet hero: it means the watcher can poll every ten seconds without thrashing your files. Nothing changes on disk until the contract actually changes.
The generated calls are Result-based: every method returns { data, error } and never throws. No try/catch around every fetch, no surprise exceptions bubbling out of a loader. You check error, or you use data — and TypeScript narrows the type for you on each branch. (The full Result ergonomics — error codes, normalization, type guards — live in the generated api.error.ts and its type-guard helpers.)
Configuration lives in api.config.toml
One file at the repo root tells the CLI where the spec is, where to write the client, and how the axios instance should behave. It's short on purpose.
api_endpoint = "https://tunnel.chowbea.com/docs/swagger/json"
poll_interval_ms = 10000
[output]
folder = "app/services/api"
[instance]
base_url_env = "VITE_API_URL"
token_key = "auth-token"
with_credentials = true
timeout = 30000Two lines deserve a callout. poll_interval_ms = 10000 is the watcher's heartbeat — ten seconds. And base_url_env = "VITE_API_URL" is the bridge between this config and your .env: it tells the generated axios instance to read its baseURL from VITE_API_URL at runtime.
VITE_API_URL is the one env var this client can't run without
The generated api.instance.ts sets baseURL: import.meta.env.VITE_API_URL. If that variable is missing, every request fires at undefined and fails. Set it in your .env to https://tunnel.chowbea.com — and remember Vite inlines it at build time, so changing it later means a rebuild, not a restart. See Getting Started for the full env inventory.
Generated files vs. files you own
This is the single most important thing to internalize before you touch app/services/api/. The folder is half robot, half yours, and the line between them is non-negotiable.
Everything under _generated/ is overwritten on every run — the headers literally say "DO NOT EDIT MANUALLY." The sibling wrapper files were generated once and then handed to you; their headers say "You can safely modify this file — it will NOT be overwritten." Custom logic — an interceptor tweak, a helper, a default header — goes in the wrappers. Never in _generated/.
| File | Owner | Edit it? |
|---|---|---|
_generated/api.types.ts | openapi-typescript | No — regenerated from the spec |
_generated/api.operations.ts | chowbea-axios | No — exposes api.op.<operationId>() |
api.client.ts | you (generated once) | Yes — the typed api object you import |
api.instance.ts | you (generated once) | Yes — axios instance + auth interceptor |
api.error.ts / api.helpers.ts | you (generated once) | Yes — error shaping + helpers |
If you edited _generated/, you've already lost it
The next spec change — or the next pnpm dev:all poll — silently overwrites anything under app/services/api/_generated/. There is no warning. Put your code in the sibling api.client.ts / api.instance.ts / api.error.ts / api.helpers.ts files, which the generator leaves alone.
Calling the API
You import one thing — the api object from api.client.ts — and it gives you two ways to call. There's the semantic form, api.op.<operationId>(), where the operationId comes straight from the backend spec; this is what the codebase actually uses. And there's the raw verb form, api.get/post/put/patch/delete(path), when you'd rather think in HTTP.
The real queries wrap calls in a BaseQuery class, but the call itself is the same api.op.* you'd write anywhere. Here's the website-form query, lightly trimmed — note that every method just reads .data off the Result:
import BaseQuery from "../base.query";
import type { CreateWebsiteForm } from "./interfaces/website-form.dto";
class WebsiteQueries extends BaseQuery {
create = async (data: CreateWebsiteForm) => {
const response = await this.api.op.submitWebsiteForm(data);
return response.data;
};
get = async (id: string) => {
const response = await this.api.op.getWebsiteForm({ id });
return response.data;
};
}When you want to handle the error rather than ignore it, destructure the Result and branch. TypeScript narrows data to non-null on the success side, so there's no ! or optional-chaining gymnastics:
import { api } from "@/services/api/api.client";
const { data, error } = await api.op.getWebsiteForm({ id });
if (error) {
logger.error("form fetch failed", { code: error.code });
return null; // never throws — this is just a value
}
// data is fully typed and non-null here
return data;BaseQuery is a JS singleton, not a Sanity thing
app/queries/base.query.ts is a plain class holding the shared api and logger. The word "singleton" also shows up in the Sanity docs for a totally different concept — fixed-_id documents. They collide in name only. See Single Documents & Singletons if that's the one you're after.
The api:* scripts
The CLI is a global install (chowbea-axios, not a repo dependency), and the root package.json wraps it in api:* scripts so you never type raw CLI flags. You'll reach for api:status and api:watch daily; the rest are situational.
| Script | What it does |
|---|---|
pnpm api:status | Show config, cache state, endpoint counts, and which files are present. Run this first when something looks off. |
pnpm api:fetch | One-shot: pull the live spec and regenerate. |
pnpm api:generate | Regenerate from the cached spec — no network call. |
pnpm api:watch | Poll the endpoint every poll_interval_ms and regenerate on change. |
pnpm api:diff | Preview the delta between current and incoming spec before regenerating. |
pnpm api:validate | Validate the spec (--strict treats warnings as errors). |
chowbea-axios is global-only — a fresh clone can't regenerate without it
The CLI is not in package.json, node_modules, or the lockfile. A fresh pnpm install does not provide it, so every api:* script fails with command not found until you install it globally:
pnpm add -g chowbea-axiosWhen do I actually regenerate?
Almost never, and that's by design — the generated files are committed. A fresh clone type-checks and boots immediately; you do not run codegen just to start working. You regenerate in exactly one situation: the backend spec changed — a new endpoint, a renamed field, a tweaked response shape.
When that happens, you have two paths. For a one-off, run pnpm api:diff to see what moved, then pnpm api:fetch to apply it. But the smoother path during active feature work is to let the watcher do it for you.
pnpm dev:all runs the API watcher and Vite side by side via concurrently, as two named processes:
pnpm dev:allapi=pnpm api:watch— pollinghttps://tunnel.chowbea.com/docs/swagger/jsonevery 10s.Vite=pnpm dev— the dev server on http://localhost:5173.
The moment the backend ships a spec change, the watcher rewrites app/services/api/_generated/*, Vite hot-reloads, and your types are current — no manual step, no stale client. If you're building a feature that touches a brand-new backend endpoint, dev:all is the command you want running.
Plain pnpm dev does not regenerate
pnpm dev starts only Vite — the API client is frozen at whatever is on disk. That's fine when you're not touching the API contract. The moment you expect backend changes, switch to pnpm dev:all so the watcher is live alongside it.
Where to go next
Getting Started
Install the CLI, set VITE_API_URL, and run the dev servers end to end.
The Stack
Where axios, the generated client, and every other dependency fit.
Troubleshooting & Gotchas
The global-CLI requirement and other API-client traps.
Forms
A real flow that posts through this client to the backend.