Naalya Handbook

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.config.toml
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 = 30000

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

FileOwnerEdit it?
_generated/api.types.tsopenapi-typescriptNo — regenerated from the spec
_generated/api.operations.tschowbea-axiosNo — exposes api.op.<operationId>()
api.client.tsyou (generated once)Yes — the typed api object you import
api.instance.tsyou (generated once)Yes — axios instance + auth interceptor
api.error.ts / api.helpers.tsyou (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:

app/queries/website-form/website-form.queries.ts
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:

example — the Result pattern
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.

ScriptWhat it does
pnpm api:statusShow config, cache state, endpoint counts, and which files are present. Run this first when something looks off.
pnpm api:fetchOne-shot: pull the live spec and regenerate.
pnpm api:generateRegenerate from the cached spec — no network call.
pnpm api:watchPoll the endpoint every poll_interval_ms and regenerate on change.
pnpm api:diffPreview the delta between current and incoming spec before regenerating.
pnpm api:validateValidate 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:

terminal
pnpm add -g chowbea-axios

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

terminal
pnpm dev:all
  • api = pnpm api:watch — polling https://tunnel.chowbea.com/docs/swagger/json every 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

On this page