Palbase
Sign inGet started

Web SDK

Next.js

@palbase/web ships three Next.js App Router entry points: @palbase/web/next (the server half — Server Components, Server Actions, Route Handlers, the OAuth callback), @palbase/web/next/proxy (proactive session refresh) and @palbase/web/next/client (the browser half, which moves the session into cookies). Together they make the browser and the server share one session, so the same pb.todos.list() runs in a Server Component, in a Route Handler and in a client component with the user's session attached everywhere. The adapter needs next >= 16 — the proxy file convention is a Next 16 feature — and Next is an optional peer dependency: importing @palbase/web/next never loads next at module scope, so it is safe in a package that does not have Next installed.

Quick example

Four files. Steps 1–3 are required for sessions to work at all; step 4 only if you sign users in with OAuth. palbase link writes providers.tsx and proxy.ts for you and splices the palbase/client barrel import into your entry — you add the <Providers> wrapper.

// app/layout.tsx — configures pb for the server module graph
import '../palbase/client';
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/providers.tsx — written by `palbase link`; configures pb for the client bundle
'use client';
import '../palbase/client';
import { setupPalbeNext } from '@palbase/web/next/client';

setupPalbeNext();

export function Providers({ children }: { children: React.ReactNode }) {
  return children;
}
// proxy.ts — project root or src/, NEVER inside app/
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).*)'],
};
// app/auth/callback/route.ts — OAuth only
import { handleAuthCallback } from '@palbase/web/next';

export const GET = handleAuthCallback({ defaultNext: '/' });

The entry points

@palbase/web publishes exactly six public subpaths plus one binary. Three of them are the Next adapter:

SubpathSideWhat it exports
@palbase/web/nextserverpbServer, handleAuthCallback, the session-cookie codec
@palbase/web/next/proxyrequest pathpalbeProxy, PalbeProxyConfig, PalbeProxyOptions
@palbase/web/next/clientbrowsersetupPalbeNext, cookieSessionStorage

The other three are . (the pb singleton), ./react (hooks) and ./internal (what the generated file calls). The binary is palbe-gen.

Warning: There is no @palbase/web/next/middleware subpath and no palbeMiddleware export. Three comments inside the SDK's own source still name them — a reader who greps node_modules will find those comments and believe the export exists. It does not, and never shipped under that name. The middleware-shaped thing is palbeProxy from @palbase/web/next/proxy.

@palbase/web/next never imports next/headers or next/server at the top level — Next is loaded lazily, inside the function bodies that need it. A golden test locks that module graph, which is what makes the import safe outside a Next app.

Wiring: the four pieces

1. Import the generated file in the root layout

There is no createClient(url, key) in this SDK. The CLI-generated palbe.gen.ts calls __configure({ url, apiKey, appId }) from @palbase/web/internal at import time. Your app imports the palbase/client barrel rather than that file directly — the barrel export *s whichever environment PALBASE_ENV selected — so one side-effect import of the barrel both configures pb and types it. The layout's top-level import configures pb for Server Components, Server Actions and Route Handlers, which share the root layout's module graph.

The generated file also exports environmentConfig ({ url, apiKey } as const) for server code that needs the raw pair — see the warning in step 3 about where not to use it.

2. Configure the browser with setupPalbeNext()

Client bundles have their own module graph, so the gen file must be imported there too. That is what providers.tsx is for. Without it rendered in your layout, the browser keeps the session in localStorage, the server never sees it, and every client-side pb call throws the not-configured error:

Palbe is not configured. Run 'palbase link' in your project and make sure palbe.gen.ts is imported once at app startup.

Note: <Providers> is your app's code, written into your repo by palbase link (it merges into an existing providers file rather than clobbering it). It is not an SDK export, and @palbase/web/react ships no provider and no context at all — see React Hooks.

3. Add the session-refresh proxy

palbase link writes proxy.ts with your url and publishable apiKey filled in — the same pair it bakes into palbe.gen.ts. It places the file at the project root or under src/, never inside app/, and it never overwrites an existing proxy.ts/proxy.js or middleware.ts/middleware.js: it prints the line to add by hand instead.

Warning: the proxy is required, not optional, for any app that signs users in and renders Server Components. See palbeProxy for what goes wrong without it.

Two rules the shape in Quick example encodes:

  • Import @palbase/web/next/proxy, not @palbase/web/next — and import palbase/config, never palbase/client or the generated client. Next compiles the proxy into its own bundle, and the full runtime graph reaches livekit-client and the MLS WASM loader — measured at 2.4 MB on a real app, against a proxy entry that is a few kilobytes. palbase/config.ts is a one-line barrel over an import-free leaf the generator rewrites on every run, so it brings nothing with it; palbase/client.ts re-exports palbe.gen.ts, which calls __configure at import time and pulls that whole graph back in. A bundle test holds the line against the real build output, measuring each proxy entry and the chain a consumer actually compiles (app proxy → generated leaf → this entry): the graph reaches no livekit, messaging, wasm or calls module, externalizes nothing but next/server, never calls WebAssembly.compile, and stays under a 256 KiB budget — followed to the leaves, because a leak left external weighs nothing and slips past a budget measured any other way. A negative control builds ./next the same way and confirms it still fails every one.
  • Declare config in this file. Next reads export const config off this file's own AST, so a re-exported or imported one is silently ignored and the proxy degrades to matching every request.

Note: proxy is the Next 16 file convention. If your app still has a middleware.ts, rename it — Next deprecated the filename and its middleware export and warns on every next dev / next build. npx @next/codemod middleware-to-proxy . does it for you, and that is exactly what palbase link suggests when it finds one.

4. (OAuth only) add the callback route

Import the palbase/client barrel in the callback route so its server module graph is configured independently of a layout render.

pbServer — server-side data access

export async function pbServer(opts?: { cookies?: CookieStoreLike }): Promise<PB>;
// app/todos/page.tsx — a Server Component
import { pbServer } from '@palbase/web/next';

export default async function TodosPage() {
  const pb = await pbServer();
  const todos = await pb.todos.list({ limit: 20 });
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Every call builds a fresh runtime over the request's cookie store and returns a PB permanently bound to it — typed namespaces, pb.call, pb.upload and pb.auth all resolve that captured runtime rather than the process-global one, so two concurrent requests can never leak each other's sessions. It reads the session the browser SDK wrote (same cookie format), refreshes it pre-flight when expired, and — where the cookie store is writable (Server Actions, Route Handlers) — persists the rotated session back.

It works the same in a Server Action:

// app/todos/actions.ts
'use server';
import { pbServer } from '@palbase/web/next';

export async function createTodo(title: string) {
  const pb = await pbServer();
  return pb.todos.create({ title });
}
PropertyBehavior
CostCheap — a handful of small objects, no I/O. Call it per data access.
MemoizationOptional. If one request calls it many times you may wrap it in React's cache(); you do not have to.
Outside a requestThrows a validation BackendError with code next_required.
Not configuredThrows a notConfigured BackendError with code not_configured, naming the missing import './palbe.gen' in app/layout.tsx.
During next buildNext's control-flow errors (digest === 'DYNAMIC_SERVER_USAGE', or any NEXT_-prefixed digest) pass through unwrapped, so a page calling pbServer() is correctly marked dynamic instead of failing the build.
Custom storepbServer({ cookies }) accepts any object matching CookieStoreLike — the seam for tests and non-Next servers.
// Optional per-request memoization
import { cache } from 'react';
import { pbServer } from '@palbase/web/next';

export const getPb = cache(() => pbServer());

CookieStoreLike is a structural slice of Next's request cookie store:

export interface CookieStoreLike {
  get(name: string): { name: string; value: string } | undefined;
  getAll?(): Array<{ name: string; value: string }>;
  set?(name: string, value: string, options?: Record<string, unknown>): void;
  delete?(name: string): void;
}

Server Components cannot write cookies — their cookie store is read-only, and the adapter deliberately swallows per-cookie write failures there rather than crashing your render. The request still proceeds with the refreshed session in memory, but the rotated refresh token is not persisted back to the browser.

Without palbeProxy, every Server Component render re-refreshes from the same cookie refresh token. Refresh tokens rotate on use, and once two refreshes from one token land more than the auth server's 30-second rotation grace apart, its reuse detector treats the second as token theft and revokes the entire token family — the user is force-logged-out everywhere. The proxy refreshes the cookie before the Server Component tree renders, so pbServer always hydrates a still-valid session and never burns the token itself.

Note: pbServer only ever writes full sessions (an access token and an expiry). A refresh-token-only save is silently skipped rather than degrading a cookie the browser may still be using.

The global pb on the server

The process-global pb singleton works in server code for anonymous-key calls, but never sign in through it in shared server code — that would put one user's session into process-global state shared across every request. Use pbServer() for anything session-bearing. See Overview for the full browser-versus-server behavior matrix.

palbeProxy — proactive session refresh

export async function palbeProxy(
  request: NextRequest,
  config: PalbeProxyConfig,
  opts?: PalbeProxyOptions,
): Promise<NextResponse>;

export interface PalbeProxyConfig {
  url: string;    // https://<ref>.palbase.studio
  apiKey: string; // publishable only — pb_project_c...
}

export interface PalbeProxyOptions {
  refreshMarginMs?: number; // default 60_000
  response?: NextResponse;
}
OptionTypeDefaultDescription
refreshMarginMsnumber60000Refresh when the access token expires within this window.
responseNextResponseA pre-built response to decorate instead of NextResponse.next({ request }).

What it does per request:

Incoming session stateOutcome
No session cookieUntouched pass-through.
Access token expires later than refreshMarginMs from nowUntouched pass-through.
Stale or expiredOne POST <url>/auth/token/refresh; the rotated cookies are written onto both the request (so this render sees them) and the response (so the browser stores them).
Refresh answered 400/401/403Terminal — the session cookies are cleared and the user is signed out.
Refresh failed transiently: network error, 5xx, or a malformed 200Pass-through; the session is kept untouched. Never destroyed over a blip.

Touched responses get Cache-Control: private, no-store, so an auth outcome is never cached. Concurrent invocations holding the same refresh token — parallel tabs, prefetches — collapse onto a single refresh call per process, so they cannot race each other into the reuse detector.

The key must be the publishable one. A service-role key is rejected loudly, with a validation BackendError carrying code validation_error, rather than being tolerated: a key the parser cannot read yields an empty ref, which would name the cookie palbe-session- and quietly turn the proxy into a no-op — the exact failure it exists to prevent. See the two API keys for the two keys and which is which.

Warning: skipping the proxy in a session-bearing Server Component app leads to session-family revocation and forced logout. It is the most common "my users keep getting logged out" root cause — wire the proxy first.

Note: if you pass a pre-built response from your own chained logic, that response snapshotted the request headers when it was created, so a cookie rotated by palbeProxy reaches the browser via Set-Cookie but not this request's downstream render. Prefer running palbeProxy first and building your response after it.

handleAuthCallback — completing OAuth

Start in a Server Action or Route Handler, where the SDK can write the transaction's HttpOnly proof cookie:

'use server';
import '@/palbase/client';
import { pbServer } from '@palbase/web/next';
import { redirect } from 'next/navigation';

export async function signInWithGoogle() {
  const pb = await pbServer();
  const transaction = await pb.auth.beginOAuth({
    provider: 'google',
    redirectTo: 'https://app.example.com/auth/callback',
  });
  redirect(transaction.authorizationURL);
}

Configure that exact return URI, without query parameters. The backend handles the provider callback and sends transaction_id and result_code to the app:

// app/auth/callback/route.ts
import '@/palbase/client';
import { handleAuthCallback } from '@palbase/web/next';
export const GET = handleAuthCallback({ defaultNext: '/todos' });

The handler checks the stored proof and exact callback before completing. Success writes the session cookies and redirects to the configured same-origin destination. A link result redirects without creating a session. Errors redirect with auth_error; temporary failures retain the proof for a retry. Responses are private and uncached.

An MFA result returns HTTP 202 JSON by default, with no session cookies. Supply onMFARequired(result, request) to hand the challenge to your MFA UI. The challenge token is never put into a redirect URL. Provider code/state callbacks and arbitrary next query parameters are not accepted by this route.

Proof cookies are scoped to individual transactions, use HttpOnly, Secure, SameSite=Lax, and expire within ten minutes. Use HTTPS for the app. Browser flows started on the browser's pb use tab storage and must complete on a browser callback page; they cannot be completed by this server handler. pbServer() in a read-only Server Component cannot start a flow that needs to write cookies.

export function setupPalbeNext(): void;
export function cookieSessionStorage(environmentRef: string): SessionStorageAdapter;

By default the browser SDK persists the session in localStorage, which is invisible to the server. setupPalbeNext() re-configures the already-loaded client with cookie-backed session storage so the browser and the server read and write the same bytes. It is:

  • a one-liner in a client component, after the palbase/client barrel import (it throws the guided not-configured error otherwise),
  • a no-op when the module is evaluated server-side (no document),
  • safe to call again — under Fast Refresh it simply re-configures.

Once it has run, a browser-side pb.auth.signIn(...) writes the session into cookies and the very next request to your app carries it: pbServer() sees the signed-in user with no extra plumbing. @palbase/web/next/client never imports next itself, so it is safe in any client bundle. cookieSessionStorage(ref) is the underlying adapter if you need it directly.

PropertyValue
Namepalbe-session-<stack-ref> — on the Palbase cloud that is the constant palbe-session-project
ChunkingValues over 3500 bytes split into .0, .1, … suffix cookies and are reassembled transparently
AttributesPath=/; SameSite=Lax; Secure; Max-Age=2592000 (30 days — the refresh-token TTL)
HttpOnlyNo, by design — the browser SDK must read and write the session itself
ScopeFirst-party to your app's origin only. It is never sent to the Environment's host; API auth rides the Authorization header.

The cookie name's suffix comes from the ref half of your publishable key — and on this cloud that half is the compile-time constant project for every Environment, not your Environment's address. That is the same fact the two API keys documents, and it has one consequence here:

Warning: two Palbase Environments served from the same browser origin collide. Both would write palbe-session-project, and both would share palbe.session.project, palbe.flags.project and palbe.analytics.id.project in localStorage. Signing in against one overwrites the other's session. If you need two Environments in one app, put them on different origins.

The codec the adapter uses is exported from @palbase/web/next for custom servers and custom cookie jars: sessionCookieName, encodeSessionCookies, encodeSessionCookiesDecoded, decodeSessionCookies, clearedSessionCookieNames, environmentRefFromApiKey, SESSION_COOKIE_ATTRS, plus the StoredSession and SessionCookieWrite types. Most apps never touch them. If you do write cookies yourself, honour the clear list a SessionCookieWrite carries — it holds the one-past-the-end chunk name, which is what stops an orphaned chunk from an older, longer session joining a fresh one.

Warning — the Secure-cookie trap: session cookies are written with Secure unconditionally. That works on http://localhost (browsers treat localhost as a trustworthy origin), but on a plain-http LAN address — testing on a phone against http://192.168.1.20:3000 — the browser silently drops the writes and the session will not persist. Nothing errors; sign-in just does not stick. Use HTTPS or a localhost tunnel for on-device testing.

What does not work server-side

pbServer() gives you the full typed PB, but three facades behave differently off the browser, and one of them throws:

SurfaceServer behavior
pb.realtime.channel(name)Throws a validation BackendError with code realtime_unavailable. Reading pb.realtime.status.state is always safe and reports 'idle'.
pb.flagsNever auto-starts polling. ready() and refresh() become one-shot fetches, so a request that reads flags pays exactly one HTTP call. See Flags.
pb.messagingThe MLS store falls back to in-memory — nothing persists between requests. Chat belongs in the browser. See Messaging.

API reference

From @palbase/web/next (server):

ExportDescription
pbServer(opts?)Per-request, session-bound PB for Server Components, Server Actions and Route Handlers
handleAuthCallback(opts?)OAuth callback GET handler factory
sessionCookieName(ref)palbe-session-<ref>
encodeSessionCookies · encodeSessionCookiesDecoded · decodeSessionCookies · clearedSessionCookieNamesThe session-cookie codec (advanced)
environmentRefFromApiKey(apiKey)The ref half of a key — on this cloud, the stack ref project. '' if the key is malformed.
SESSION_COOKIE_ATTRS{ path: '/', sameSite: 'lax', secure: true, maxAge: 2592000 }
TypesCookieStoreLike, HandleAuthCallbackOptions, StoredSession, SessionCookieWrite

From @palbase/web/next/proxy (request path — keep this import out of your app graph):

ExportDescription
palbeProxy(request, config, opts?)Proactive cookie-session refresh, before the render
TypesPalbeProxyConfig, PalbeProxyOptions

From @palbase/web/next/client (browser):

ExportDescription
setupPalbeNext()Switch the configured client to cookie-backed session storage
cookieSessionStorage(ref)The underlying document.cookie storage adapter
  • Codegenpalbase link, palbe-gen, and the generated palbe.gen.ts
  • Auth — sign-in flows, OAuth, and the session in the browser
  • React Hooks — the client-component half, and why there is no provider component
  • Error Handling — the seven BackendError kinds, including next_required and not_configured
  • Overview — the pb surface and what runs where
  • the two API keys — the publishable key, the service-role key, and the ref that is not your address
  • Introduction — the address url points at