Palbase
Sign inGet started

Web SDK

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:

PathCommitted?What it is
palbase/environments/<env>/openapi.jsonyesthe contract palbe-gen reads
palbase/environments/<env>/web-config.jsonyesflat runtime config — app_id, base_url, api_key, optional oauth
palbase/environments/<env>/palbe.gen.tsyesthe generated client for that environment
palbase/client.tsyesthe 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>/.

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:

  1. Resolves the target — a project by name, id, environment ref or environment address, an address that stays one, or the checkout's existing binding.
  2. Fetches the publishable key, contract and selected OAuth snapshot over authenticated routes. OAuth selection uses the application_key and variant under oauth.web in palbase/project.json; see OAuth configuration.
  3. Stages the input artifactspalbase/environments/<env>/openapi.json and palbase/environments/<env>/web-config.json (the config at mode 0600). Commit both; they are palbe-gen's inputs.
  4. Installs @palbase/web@latest if needed using your project's package manager (npm, pnpm, yarn or bun). If installation leaves the generator unavailable, link fails and preserves the previous artifact set.
  5. Runs codegen, invoking the project-local palbe-gen to write that environment's palbe.gen.ts and the palbase/client.ts barrel 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's web-config.json is still written, but no client is generated and nothing below is wired — the link prints the 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 the palbase link after the push, not with the push.
  6. Adds predev and prebuild scripts to package.json: palbe-gen --soft || exit 0, or palbe-gen --out "<file>" --soft || exit 0 for a non-default output. Every npm run dev and npm run build now regenerates from the committed spec. Existing different scripts are left alone with a warning, and the patch is byte-surgical — the rest of your package.json is never reformatted.
  7. Writes the environment config: an import-free leaf beside the generated client (palbe.config.ts) and a one-line barrel at palbase/config.ts. Both are rewritten on every run from PALBASE_ENV, exactly like palbase/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.
  8. 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 to palbase/client from 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.
  9. (Next.js App Router) Writes app/providers.tsx — a 'use client' component that imports the barrel for the browser bundle and calls setupPalbeNext(). It never overwrites an existing providers.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 configures pb for Server Components only, and the browser bundle has its own module graph.
  10. (Next.js App Router) Writes proxy.ts at the root, or src/proxy.ts for a src/ layout. It carries no address and no key — it imports palbase/config, the barrel described above, with the relative path its own location needs. Same non-destructive rule — an existing proxy.ts or legacy middleware.ts is 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 import palbase/config and never palbase/client.
  11. Checks .gitignore and warns loudly if it would swallow the generated client or the barrel.
FlagDefaultDescription
--entry <file>auto-detectedEntry file that receives the barrel import
--out <file>palbe.gen.tsName 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:

ConditionMessage
file missingpalbase/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 originHTTPS is required; plain HTTP is accepted only for an exact loopback host
unsupported or malformed OAuth snapshotgeneration 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.jsonpalbase/environments/<env>/ios-config.json, .../macos-config.json, .../android-config.json. There is no map and no default_environment field 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:

  • --url does not strip your identity. It reads the linked palbase/environments/<env>/web-config.json and overrides only url; 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.
  • --watch has no localhost default. With no --url, watch polls the linked checkout's own environment base_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 on SIGINT/SIGTERM rather than truncating a write.
  • --soft is the hook's value, which is why an offline teammate's npm run build still succeeds against the committed file.
  • The barrel is written first, in every mode, even --watch — so a fresh checkout that runs palbe-gen --watch gets 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:

  • appId is part of the emitted __configure call, and PalbeConfig requires 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.js proxy.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 any Object.prototype name);
  • a reserved top-level namespace — auth, analytics, flags, realtime (matched case-insensitively), plus call, upload, then and the Object.prototype names;
  • an operation declaring both a body and query parameters, since input is 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, messaging and perf are reserved on pb but are not skipped here, so a controller with one of those names is generated normally and then throws reserved_namespace when palbe.gen.ts is 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 503 for a short while after creation; palbase link and palbase push retry 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 --soft in 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. --url overrides 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 with palbase spec and run a plain npx palbe-gen, which reads the committed palbase/environments/<env>/openapi.json rather 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.

  • Overview — the pb singleton, PalbeConfig, and the request pipeline
  • Calling Your Backend — what the generated methods look like to call
  • Error HandlingBackendError and the generated error classes
  • Next.jsproviders.tsx, proxy.ts, and environmentConfig
  • Linking a Checkoutpalbase link, and the three files that decide what a directory acts on
  • CLI: Codegenpalbase spec, and the native link / use commands
  • the two API keys — the publishable key baked into the generated file