@palbase/web
v11.0.1
Published
Palbe — the Palbase client SDK for web. One entry point: pb.
Maintainers
Readme
@palbase/web
The Palbase client SDK for web. One import, one entry point: pb.
@palbase/web is typed against your own backend. You run palbase link once, it
generates a typed client from your deployed endpoints, and from then on
pb.todos.create(...), pb.rooms.list(...) and friends are fully typed — request
shapes, response shapes and per-endpoint errors, all inferred from the backend you
shipped. Auth, feature flags, realtime and analytics come built in.
import { pb } from '@palbase/web';
await pb.auth.signIn({ email, password });
const todo = await pb.todos.create({ title: 'Ship it' }); // typed by your backendInstall
npm i @palbase/webThen install the Palbase CLI and link your project — the CLI generates the typed client:
brew install palgroup/tap/palbase
palbase link <project>palbase link creates ONE visible directory in your checkout — palbase/ —
runs palbe-gen when @palbase/web is installed, wires the generated client
into your app entry, and adds a predev/prebuild hook that keeps it in sync.
Everything that belongs to one environment lives together in that environment's
own directory, and palbase/client.ts is the single line your application
imports:
palbase/
client.ts the one import your app makes (a re-export)
environments/
local/ the stack `palbase start` runs on this machine
openapi.json the contract
web-config.json this environment's url + publishable key
palbe.gen.ts the generated typed client
main/
… same four files, for the deployed environmentCommit all of it. Nothing under palbase/ is a build artifact: the generated
client is source your app imports, so it shows up in autocomplete before any
build, changes arrive as a reviewable git diff, and a fresh clone builds with
no CLI and no network.
The generated client calls __configure(...) with the linked Environment ref,
URL, app ID, and publishable API key at module load, so importing
palbase/client.ts once at startup is all the setup there is.
If you forget to import it, every pb call throws a guided BackendError
(kind: 'notConfigured').
Regenerate any time your backend changes:
npx palbe-gen(or just rundev/build— the hook does it for you).
Which environment a build talks to
One key, and its default is local. PALBASE_ENV selects the environment;
unset means local, the stack every checkout gets for free. Set it wherever you
already set environment variables — an npm script, a .env file, your CI:
PALBASE_ENV=main npm run buildpalbe-gen reads it, generates into that environment's own directory, and
rewrites palbase/client.ts to re-export that environment's client. Your
import never carries an environment name, so switching environments never edits
your application's source — and because a bundler follows the re-export, only
the selected environment's code reaches your bundle.
Calling your backend
Codegen turns each backend controller into a typed namespace on pb. The
operation id is <controller>.<method> — TodosController.create becomes
pb.todos.create(input), HelloController.greet becomes pb.hello.greet().
Path params come first, the request body/query second:
import { pb } from '@palbase/web';
// POST /todos/create — body typed, response typed
const todo = await pb.todos.create({ title: 'Buy milk', done: false });
// GET /todos/{id} — path param, then options
const one = await pb.todos.get('todo_123');
// GET /todos — typed query object
const list = await pb.todos.list({ limit: 20, done: false });Escape hatches
When you need to call an endpoint that isn't in the generated types yet (or do a
raw multipart upload), use pb.call and pb.upload:
// Untyped POST — you supply the response type
const result = await pb.call<{ ok: boolean }>('todos/archive', { id: 'todo_123' });
// Multipart upload with progress
const uploaded = await pb.upload<{ url: string }>('files/avatar', {
file: blob,
filename: 'avatar.png',
onProgress: ({ sent, total }) => console.log(sent / total),
});Auth
pb.auth is the full authentication surface — email/password, OTP, magic links,
OAuth, password reset, email verification:
await pb.auth.signUp({ email: '[email protected]', password: 'secret123' });
const { user, session } = await pb.auth.signIn({ email: '[email protected]', password: 'secret123' });
await pb.auth.signOut();
// React to session changes
const unsub = pb.auth.onAuthStateChange((state) => {
console.log(state.status); // 'signedIn' | 'signedOut'
});
pb.auth.isSignedIn; // boolean (token presence)
pb.auth.currentUser; // AuthUser | null
await pb.auth.refreshUser(); // re-fetch the profile (e.g. emailVerified flip)Phone OTP, magic links and OAuth:
await pb.auth.signInWithOTP({ phone: '+1555…' });
await pb.auth.verifyOTP({ phone: '+1555…', token: '123456' });
await pb.auth.signInWithMagicLink('[email protected]');
// Browser flow: complete on a browser callback page with the same tab storage.
await pb.auth.signInWithOAuth({ provider: 'google', redirectTo: 'https://app.example.com/auth/callback' });OAuth uses the oauth snapshot emitted by palbase link, selecting a browser
client for this app and variant. Google, Apple, Microsoft and GitHub share the
same API. The callback receives transaction_id and result_code:
await pb.auth.exchangeCodeForSession({ transactionId, resultCode, callbackURL: location.href });The result is signedIn, mfaRequired, or linked; only signedIn installs a
session. Proofs are scoped to this browser tab, backend and app. A transient
failure can be retried with completeOAuth({ transactionId }), which reuses the
stored result. Start explicit account linking with linkIdentity; accounts are
never merged solely because their email addresses match.
For a Next.js handleAuthCallback route, begin the transaction with
(await pbServer()).auth.beginOAuth(...) in a Server Action or Route Handler.
That stores its proof in a transaction-specific HttpOnly cookie. The default MFA
response is uncached HTTP 202 JSON; onMFARequired can render your MFA handoff.
Browser session-storage proofs cannot be read by a server callback.
Feature flags
pb.flags polls and caches your project's feature flags (auth-aware — flags
re-evaluate when the user signs in/out):
if (pb.flags.isEnabled('new-checkout')) { /* … */ }
pb.flags.getString('theme', 'light');
pb.flags.getInt('max-items', 10);
await pb.flags.getVariant('pricing-experiment'); // multivariate → variant name | null
const off = pb.flags.onChange(() => console.log('flags changed', pb.flags.all()));Realtime
pb.realtime multiplexes every subscription over one auto-reconnecting
WebSocket. Channels are client-only (browser):
const room = pb.realtime.channel('room:42');
const sub = room.on('message', (payload) => {
console.log('got', payload);
});
room.send('message', { text: 'hello' }); // broadcast to other subscribers
sub.cancel(); // last cancel on a channel leaves itConnection status is observable (pb.realtime.status.state /
pb.realtime.status.onChange(...)).
Analytics
pb.analytics buffers events client-side and flushes in batches. It manages an
anonymous distinct id and stitches it to the user on sign-in automatically:
pb.analytics.capture('checkout_started', { plan: 'pro' });
pb.analytics.screen('Dashboard');
pb.analytics.identify('user_123', { email: '[email protected]' });
await pb.analytics.flush(); // force-send buffered events
pb.analytics.setOptOut(true); // GDPR opt-out (drops pending + future)Errors
Every failing call throws a BackendError. Inspect kind (a coarse category)
and code (the server's machine code):
import { pb, BackendError, isBackendError } from '@palbase/web';
try {
await pb.todos.create({ title: '' });
} catch (e) {
if (isBackendError(e)) {
e.kind; // 'validation' | 'unauthorized' | 'rateLimited' | 'notConfigured'
// | 'network' | 'server' | 'decode'
e.code; // server machine code, e.g. 'invalid_input'
e.status; // HTTP status
e.fields; // field-level validation errors (when kind === 'validation')
e.retryAfter; // seconds (when kind === 'rateLimited')
}
}Generated code also gives you typed per-endpoint error classes (e.g.
RoomsCreateRoomLockedError) with typed .data, so you can instanceof-match the
specific failures a given endpoint documents.
Next.js — @palbase/web/next
The Next.js App Router adapter shares one session cookie between the browser and
the server so Server Components, Route Handlers and the proxy all see the same
auth state. Requires next >= 16 (the proxy file convention).
1. Configure the browser client (a client provider/component):
'use client';
import { setupPalbeNext } from '@palbase/web/next/client';
import { useEffect } from 'react';
export function PalbeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { setupPalbeNext(); }, []);
return <>{children}</>;
}setupPalbeNext() swaps in cookieSessionStorage so the session is written to a
cookie the server can read.
2. Refresh the session in the proxy (required for session-bearing RSC apps):
// proxy.ts
import { palbeProxy } from '@palbase/web/next/proxy';
import { environmentConfig } from './palbase/config';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
return palbeProxy(request, environmentConfig);
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };palbase link writes this file for you, and it carries no address and no
key — only an import. palbe-gen writes palbase/config.ts on every run
from PALBASE_ENV, the same switch that selects the generated client, so the
proxy and the data client can no longer point at different stacks. Nothing in
your app holds an environment value; you choose the environment, the SDK does
the wiring.
Two rules the shape above encodes:
- Import
@palbase/web/next/proxy, not@palbase/web/next. Next compiles the proxy into its own bundle, and the full adapter graph reacheslivekit-clientand the MLS WASM loader — megabytes on every request path. This entry reaches none of it. - Import
palbase/config, neverpalbase/client. The config barrel sits over an import-free leaf, so it brings nothing with it; the client barrel re-exportspalbe.gen.ts, which calls__configureat import time and pulls that whole graph back in. It also meansconfig/matchermust be declared in this file — Next readsexport const configoff this file's own AST, so a re-exported or imported one is silently ignored and the proxy degrades to matching every request.
3. Read data in Server Components with pbServer() — a per-request,
session-isolated client:
import { pbServer } from '@palbase/web/next';
export default async function Page() {
const pb = await pbServer();
const todos = await pb.todos.list({ limit: 20 });
return <TodoList items={todos} />;
}4. Complete OAuth with a callback route handler:
// app/auth/callback/route.ts
import '@/palbase/client';
import { handleAuthCallback } from '@palbase/web/next';
export const GET = handleAuthCallback({ defaultNext: '/' });React — @palbase/web/react
Thin, concurrent-safe hooks over the observable pb.* facades. react is an
optional peer dependency — importing @palbase/web never pulls React; only
@palbase/web/react does.
'use client';
import { useUser, useSession, useFlag, useFlags, useChannel } from '@palbase/web/react';
function Profile() {
const user = useUser(); // AuthUser | null, re-renders on auth change
const { signedIn } = useSession(); // { signedIn, user }
const dark = useFlag('dark-mode', false);
const flags = useFlags(); // whole flag set
// Subscribe to a realtime event for the component's lifetime
const { status } = useChannel('room:42', 'message', (payload) => {
console.log(payload);
});
return signedIn ? <span>{user?.email}</span> : <SignInButton />;
}License
MIT
