Codegen
The web client is typed by code generation, in two steps with two different owners. The CLI fetches your backend's OpenAPI contract and the app's runtime config and commits them under palbase/environments/<env>/. The SDK ships a bin, palbe-gen, which reads those committed files offline and writes one TypeScript file — that environment's palbe.gen.ts — that calls __configure and declares a typed method for every endpoint you wrote, plus palbase/client.ts, the one barrel file your app actually imports. The CLI never generates client code, and palbe-gen never talks to the cloud. palbase link runs both steps so you rarely invoke either by hand.
Quick example
cd my-web-app
palbase link k3xq81w4m # bind this checkout to a project — writes palbase/project.json
// typed end-to-end, straight from your controllers
import { pb } from '@/lib/palbe';
const todo = await pb.todos.create({ title: 'Ship it', priority: 'high' });
You need the Palbase CLI installed and a cloud login or the credentials for your local/self-hosted stack. palbase link installs @palbase/web if needed; installation and generation must succeed before it publishes the artifacts.
What the web path writes
palbase link writes the input artifacts and generated client, all inside that environment's own directory:
| Path | Committed? | What it is |
|---|---|---|
palbase/environments/<env>/openapi.json | yes | the contract palbe-gen reads |
palbase/environments/<env>/web-config.json | yes | flat runtime config — app_id, base_url, api_key, optional oauth |
palbase/environments/<env>/palbe.gen.ts | yes | the generated client for that environment |
palbase/client.ts | yes | the barrel — the one line your app imports; re-exports whichever environment's palbe.gen.ts PALBASE_ENV selected (see Commit the generated file) |
The project binding is palbase/project.json. Native platforms write into the same environment directories with the same flat shape — palbase/environments/<env>/ios-config.json, .../macos-config.json, .../android-config.json — so the web config is not a special case; there is no map and no default_environment field on any platform any more.
palbase link takes back any .gitignore rule an older CLI left behind — a directory-wide .palbase/.palbase/ pattern it narrows or removes, and an unanchored palbase-env.d.ts line some versions appended — so every artifact under palbase/ stays trackable. Nothing under palbase/ is gitignored today: the only state that stays off this checkout entirely is per-machine, and it lives outside the repository altogether, under ~/.palbase/checkouts/<hash>/.
palbase link
Run it once in your web app's root — the directory holding package.json.
palbase link binds this checkout to a project and writes web-config.json and the contract for every environment of it, each in its own palbase/environments/<env>/. Run with no target, it links the project palbase/project.json names again — or the stack a local record on this machine points at — and rewrites every environment's files in place; that is the command to run after an environment is added or a key is rotated. Which environment the app is generated for is PALBASE_ENV; which one the CLI's own commands act on is palbase env use <name>, or --env <name> for one call. There is no palbase project use: one link is the whole binding mechanism.
What it does, in order:
- Resolves the target — a project by name, id, environment ref or environment address, an address that stays one, or the checkout's existing binding.
- Fetches the publishable key, contract and selected OAuth snapshot over authenticated routes. OAuth selection uses the
application_keyandvariantunderoauth.webinpalbase/project.json; see OAuth configuration. - Stages the input artifacts —
palbase/environments/<env>/openapi.jsonandpalbase/environments/<env>/web-config.json(the config at mode0600). Commit both; they arepalbe-gen's inputs. - Installs
@palbase/web@latestif needed using your project's package manager (npm,pnpm,yarnorbun). If installation leaves the generator unavailable, link fails and preserves the previous artifact set. - Runs codegen, invoking the project-local
palbe-gento write that environment'spalbe.gen.tsand thepalbase/client.tsbarrel that re-exports it. A generator error or missing output also fails the link. The completed inputs, generated client and wiring are published together after staging succeeds. When the environment the link reads from has nothing deployed there is no contract to generate from: every environment'sweb-config.jsonis still written, but no client is generated and nothing below is wired — the link printsthe web client is not generated yet: main has no contract — `palbase push --env main`, then `palbase link`and exits 0. Run exactly that: the client arrives with thepalbase linkafter the push, not with the push. - Adds
predevandprebuildscripts topackage.json:palbe-gen --soft || exit 0, orpalbe-gen --out "<file>" --soft || exit 0for a non-default output. Everynpm run devandnpm run buildnow regenerates from the committed spec. Existing different scripts are left alone with a warning, and the patch is byte-surgical — the rest of yourpackage.jsonis never reformatted. - Writes the environment config: an import-free leaf beside the generated client (
palbe.config.ts) and a one-line barrel atpalbase/config.ts. Both are rewritten on every run fromPALBASE_ENV, exactly likepalbase/client.ts, so the two can never name different environments. The leaf exists separately from the generated client because the Next proxy must read the environment without reaching the SDK runtime — see Next.js. - Inserts an import of the barrel into your app entry, auto-detected in order:
app/layout.tsx,app/layout.jsx,src/app/layout.tsx,src/app/layout.jsx,src/main.tsx,src/main.ts,main.tsx. It writes the relative path topalbase/clientfrom that file — never a path naming the environment, so switching environments later never touches this line. The insert is directive-prologue aware ('use client'stays first), multiline-import aware, and idempotent. If no entry is recognized it prints the exact line to add and exits 0. - (Next.js App Router) Writes
app/providers.tsx— a'use client'component that imports the barrel for the browser bundle and callssetupPalbeNext(). It never overwrites an existingproviders.tsx; at most it splices the import in, and only when the file's first line is'use client'. You must render<Providers>inside your root layout: the server-side layout import configurespbfor Server Components only, and the browser bundle has its own module graph. - (Next.js App Router) Writes
proxy.tsat the root, orsrc/proxy.tsfor asrc/layout. It carries no address and no key — it importspalbase/config, the barrel described above, with the relative path its own location needs. Same non-destructive rule — an existingproxy.tsor legacymiddleware.tsis never touched, and the note then names the exact call to add. See Next.js for why the proxy is required rather than optional, and why it must importpalbase/configand neverpalbase/client. - Checks
.gitignoreand warns loudly if it would swallow the generated client or the barrel.
| Flag | Default | Description |
|---|---|---|
--entry <file> | auto-detected | Entry file that receives the barrel import |
--out <file> | palbe.gen.ts | Name of the generated file, written inside the environment's own directory |
palbase unlink removes the binding only. The generated file, the barrel, the committed palbase/ artifacts and the predev/prebuild scripts all stay in place.
To point the checkout at another project, run palbase link <project-or-url>.
It fetches the new project's configuration and regenerates the client. OAuth
metadata from the previous backend is not merged into the new snapshot.
web-config.json
The web config is flat and holds one environment, committed at palbase/environments/<env>/web-config.json:
{
"app_id": "app_web",
"base_url": "https://k3xq81w4m.palbase.studio",
"api_key": "pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6",
"oauth": {
"contract_revision": 1, "config_revision": "2", "environment_ref": "project",
"application_key": "consumer", "platform": "web", "variant": "release",
"clients": [{"key":"google-web","provider":"google","mode":"browser","return_uris":["https://app.example.com/auth/callback"]}]
}
}
palbe-gen requires app_id, base_url and api_key to be present and non-empty. oauth is optional. It deliberately carries no top-level environment_ref: the environment's identity is its base URL, and the key does not encode it — see the two API keys.
Common validation failures:
| Condition | Message |
|---|---|
| file missing | palbase/environments/<env>/web-config.json is required in dir mode — run \palbase link`` |
| unparseable | … is not valid JSON: <msg> |
| required field blank | … is missing nonempty required field <field> |
| key is not publishable | … api_key does not contain a valid publishable project identity |
| invalid URL or insecure remote origin | HTTPS is required; plain HTTP is accepted only for an exact loopback host |
| unsupported or malformed OAuth snapshot | generation fails with the invalid field; old OAuth config is not decoded |
Note: The native platforms write the same flat shape, one file per environment beside the same per-environment
openapi.json—palbase/environments/<env>/ios-config.json,.../macos-config.json,.../android-config.json. There is no map and nodefault_environmentfield on any platform any more: every environment has its own directory, so the shape that used to differ between web and native is now one shape for all of them. See CLI: Codegen and iOS: Codegen.
palbe-gen
The generator itself, shipped as a bin in @palbase/web. It reads the committed palbase/environments/<env>/openapi.json and writes that environment's palbe.gen.ts, plus the palbase/client.ts barrel — offline, no CLI, no login. This is what the predev/prebuild hook runs. The environment is chosen by the PALBASE_ENV environment variable; unset means local, the stack every checkout gets for free.
npx palbe-gen # from the committed palbase/environments/local/openapi.json
npx palbe-gen --url https://k3xq81w4m.palbase.studio # from a deployed Environment's live spec
Its usage string, verbatim:
Usage: palbe-gen [--dir palbase/environments] [--out palbe.gen.ts] [--url <specOrigin>] [--soft] [--watch]
Generate the typed palbe.gen.ts web client for the @palbase/web SDK.
The environment is chosen by the PALBASE_ENV environment variable; unset means
`local`, the stack every checkout gets for free. Inputs are read from that
environment's own directory, and `palbase/client.ts` is written as the one line
your application imports — so switching environments never edits your source.
--dir <dir> Directory holding the per-environment artifacts written by
`palbase link`: <dir>/<env>/{openapi.json, web-config.json}
(default: palbase/environments). Role definitions travel
inside openapi.json as `x-palbase-roles`.
--out <file> Output file name, written inside the environment's directory;
an absolute path is used as given (default: palbe.gen.ts)
--url <origin> Fetch the spec from <origin>/openapi.json instead of --dir
(e.g. a deployed Environment's origin)
--soft Never fail: print a warning and exit 0 on any error
(for predev/prebuild hooks)
--watch Poll the --url origin every second and regenerate whenever
the spec changes. Without --url, polls the linked
checkout's own Environment (the base_url committed to
<dir>/<env>/web-config.json by `palbase link`)
-h, --help Show this help
Four details that matter more than the flag list:
--urldoes not strip your identity. It reads the linkedpalbase/environments/<env>/web-config.jsonand overrides onlyurl; the app id, the API key and the OAuth block are retained, and the file is still required. There is no mode in which the generator emits an empty API key.--watchhas no localhost default. With no--url, watch polls the linked checkout's own environmentbase_url. It hashes the spec body and skips unchanged ones, warns once about a bad body and remembers it, and aborts its tick source cleanly onSIGINT/SIGTERMrather than truncating a write.--softis the hook's value, which is why an offline teammate'snpm run buildstill succeeds against the committed file.- The barrel is written first, in every mode, even
--watch— so a fresh checkout that runspalbe-gen --watchgets the one line its app imports immediately, rather than only once the poll loop is stopped.
With no spec present in --dir mode it errors:
no OpenAPI spec at palbase/environments/local/openapi.json — run `palbase spec` first, or pass --url to fetch it from an origin
Output lines
✓ wrote palbe.gen.ts (29 operations, deploy v9f1c2ab)
The deploy clause comes from the contract's own x-palbase-deploy. A contract that does not carry one prints no deploy clause rather than a guessed identity — there is no placeholder to misread. In watch mode the line reads regenerated palbe.gen.ts (29 operations…), and transitions print waiting for <url>…, <url> responded with an error (HTTP <n>) and watch stopped.
Anatomy of palbe.gen.ts
The generated file is self-contained and does four things, in this order — abridged from the generator's own golden fixture:
// AUTO-GENERATED by `palbe-gen` — DO NOT EDIT.
// Regenerate: palbe-gen (or automatically via the predev/prebuild script)
import type { BackendError, CallOptions } from '@palbase/web';
import { __configure, __registerNamespaces } from '@palbase/web/internal';
// 1. Configure the pb singleton (runs on import)
__configure({
url: 'https://k3xq81w4m.palbase.studio',
apiKey: 'pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6',
appId: 'app_web',
oauth: {
contract_revision: 1, config_revision: '2', environment_ref: 'project',
application_key: 'consumer', platform: 'web', variant: 'release',
clients: [{ key: 'google-web', provider: 'google', mode: 'browser', return_uris: ['https://app.example.com/auth/callback'] }],
},
});
/** The Environment this file was generated against. Server code reads it from here. */
export const environmentConfig = {
url: 'https://k3xq81w4m.palbase.studio',
apiKey: 'pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6',
} as const;
// 2. Request/response/error-data types, one per endpoint schema
export interface TodosGetResponse { id: string; title: string; }
export interface TodosListQuery { done?: boolean; limit: number; }
export interface TodosListResponseItem { id: string; }
export type TodosListResponse = TodosListResponseItem[];
export interface RoomsCreateRequest { capacity?: number; kind: 'public' | 'private'; name: string; }
export interface RoomsCreateResponse { id: string; score: number | null; tags: string[]; }
/** room_locked (409): Room is locked */
export interface RoomsCreateRoomLockedData { locked_until: string; }
// 3. A typed error class for every error your backend declares
export class RoomsCreateRoomLockedError extends Error {
readonly name = 'RoomsCreateRoomLockedError';
readonly code = 'room_locked';
readonly status = 409;
readonly data: RoomsCreateRoomLockedData;
readonly cause: BackendError;
constructor(cause: BackendError) {
super(cause.message); this.cause = cause; this.data = cause.data as RoomsCreateRoomLockedData;
}
}
// 4. The endpoint descriptor tree + the module augmentation that types pb
// codegen: skipped reserved namespace "auth"
__registerNamespaces({
rooms: {
create: {
method: 'POST',
path: '/rooms/create',
errors: { room_locked: (e) => new RoomsCreateRoomLockedError(e) },
},
},
todos: {
get: { method: 'GET', path: '/todos/{id}', pathParams: ['id'], input: 'none' },
list: { method: 'GET', path: '/todos', input: 'query' },
},
});
declare module '@palbase/web' {
interface PB {
rooms: {
create(input: RoomsCreateRequest, options?: CallOptions): Promise<RoomsCreateResponse>;
};
todos: {
get(id: string, options?: CallOptions): Promise<TodosGetResponse>;
list(query: TodosListQuery, options?: CallOptions): Promise<TodosListResponse>;
};
}
}
Two parts of that block are easy to miss and both are load-bearing:
appIdis part of the emitted__configurecall, andPalbeConfigrequires it. A hand-written config missing it will not build.- The file exports
environmentConfig—{ url, apiKey } as const. That is what server-only code reads, most usefully the Next.jsproxy.ts, which must not import the gen file's runtime half.
A single import './palbase/client' therefore configures the runtime and types pb; there is no separate setup call. The barrel export *s this file, so every exported interface and error class is importable from the barrel too — that is the form to use in application code, since importing palbe.gen.ts directly would mean naming an environment. See Calling Your Backend and Error Handling. The recommended consumption pattern is a small wrapper module; see the wrapper-module pattern.
Naming
The namespace comes from the controller's CLASS NAME, not from its path. MembersController.list becomes pb.members.list: the emitter drops a trailing Controller and lowercases what is left, and the route path is not consulted at all. So @Controller("/orgs/:id/people") on a class named MembersController is still pb.members.*, and renaming the class renames the client method while renaming the path does not.
Operation ids are dot-segmented: rooms.create becomes pb.rooms.create. Type names are the PascalCase concatenation of every segment plus a suffix — RoomsCreateRequest, RoomsCreateResponse, RoomsCreateRoomLockedError. Path-parameter identifiers are sanitized ({user-id} becomes userId), reserved words and the fixed argument names input, query and options get a trailing underscore, and repeats get a numeric suffix. The wire names survive on the descriptor's pathParams, so renaming is safe.
What the emitter skips
Five kinds of operation are dropped rather than generated, each leaving a // codegen: skipped … comment in the file:
- a reserved nested segment (
then, or anyObject.prototypename); - a reserved top-level namespace —
auth,analytics,flags,realtime(matched case-insensitively), pluscall,upload,thenand theObject.prototypenames; - an operation declaring both a body and query parameters, since
inputis single-valued; - a query parameter that is not a primitive, which the serializer would refuse anyway;
- a key claimed as both a namespace and a method.
None of those fails the generation. The namespace simply never appears on pb, so read the comments if a call you expected is missing.
Warning: the emitter's top-level skip set is narrower than the runtime's reserved set.
calls,messagingandperfare reserved onpbbut are not skipped here, so a controller with one of those names is generated normally and then throwsreserved_namespacewhenpalbe.gen.tsis imported. That is a startup crash, not a missing method. See Reserved namespace names.
The x-palbase-upload extension is also not read here — web has no generated upload surface, and pb.upload is called by name.
Refreshing after a deploy
palbe-gen regenerates from the committed spec, so a backend deploy that changed your endpoints needs the spec re-fetched first. palbase push runs that refresh for you on success; by hand it is:
palbase spec # re-fetch the contract for the linked target
npx palbe-gen # regenerate palbe.gen.ts and the barrel
git add palbase/
For a web-linked checkout palbase spec writes palbase/environments/<env>/openapi.json — inside the same directory palbe-gen reads by default — so there is no directory to pass. It decides which platforms to refresh by reading the committed slot files, palbase/environments/<env>/web-config.json for web, so a fresh clone behaves like the machine that linked it.
Note: A brand-new project can answer
503for a short while after creation;palbase linkandpalbase pushretry that for up to three minutes.
Following a moving spec with --watch
npx palbe-gen --watch --url https://k3xq81w4m.palbase.studio
It generates once, then polls <origin>/openapi.json every second and regenerates only when the body changes, so your frontend types follow each deploy with no manual step. With no --url it polls the linked checkout's own environment.
Commit the generated file
palbe.gen.ts is generated, but it is not disposable build output — commit it:
- teammates and CI build from the committed file without being logged in to Palbase, which is what
--softin the hook protects; - it is the type-level contract between your frontend and the deployed backend, reviewable in a diff like any other code.
Commit palbase/environments/<env>/openapi.json and .../web-config.json alongside it, and palbase/client.ts — the barrel your app actually imports. The CLI warns at link time if .gitignore would swallow any of it.
Warning — do not ship a dev-pointed gen file.
--urloverrides the runtime URL and keeps the linked key, so a file regenerated against a dev origin is fully functional and authenticates correctly — it just talks to the wrong Environment, and nothing about it looks broken. Before shipping a release, re-fetch the deployed contract withpalbase specand run a plainnpx palbe-gen, which reads the committedpalbase/environments/<env>/openapi.jsonrather than any origin.
The zero-endpoint guard
A spec containing zero operations does not clobber an existing good palbe.gen.ts. The generator warns and keeps what you have:
warning: live spec has 0 operations — keeping existing palbe.gen.ts (fix your controllers and rerun)
On a brand-new project there is nothing to protect, so it writes the empty module and warns that palbe.gen.ts registers no calls.
An unexpectedly empty spec means the bundler found no @Controller classes to deploy. Check that your controller files are named *.controller.ts and sit where the bundler looks for them; the deploy itself still reports success, so this warning is often the first signal. It is not caused by a missing export default — a @Controller self-registers when its module is evaluated — and there is no @palbase/backend major-version gate in palbase build any more. See Controllers & Routing and Deploying.
Related
- Overview — the
pbsingleton,PalbeConfig, and the request pipeline - Calling Your Backend — what the generated methods look like to call
- Error Handling —
BackendErrorand the generated error classes - Next.js —
providers.tsx,proxy.ts, andenvironmentConfig - Linking a Checkout —
palbase link, and the three files that decide what a directory acts on - CLI: Codegen —
palbase spec, and the nativelink/usecommands - the two API keys — the publishable key baked into the generated file