The client
Your client is a component, default-exported from the file your manifest names. Xeer mounts it for you inside the provider that supplies identity and the live-update stream, so there is no entry script to write.
import { useMutation, useQuery, useState } from '@impetik/xeer/client';
import './styles.css';
export default function App() {
const notes = useQuery('notes.list', {});
const createNote = useMutation('notes.create');
const [text, setText] = useState('');
return (
<main>
<input value={text} onInput={(event) => setText(event.currentTarget.value)} />
<button onClick={() => void createNote({ text }).then(() => setText(''))}>Add</button>
{notes.loading && <p>Loading…</p>}
{notes.error && <p role="alert">{notes.error.message}</p>}
<ul>{(notes.data ?? []).map((note) => <li key={note.id}>{note.text}</li>)}</ul>
</main>
);
}JSX is wired up in the generated tsconfig.json with "jsxImportSource": "@impetik/xeer", whichever
renderer your app selected — that line is the same in every Xeer project, and the manifest decides what
it resolves to. Write className: it is the spelling that is silent under both providers. Preact
accepts the plain HTML class as well, and so does React — it renders the attribute correctly and
logs a development-mode warning asking for className. Never put both on one element. Libraries
written for React work under Preact through built-in compat — see
npm dependencies.
#Choosing the renderer
Preact is the default. Set client.runtime.provider in
xeer.app.json, or scaffold with xeer new --ui react, and the same
@impetik/xeer/client import gives you the React adapter instead:
"client": { "runtime": { "provider": "react" } }The hooks on this page — useQuery, useMutation, useAuth, useRoute, useXeerClient — have the
same names, signatures, and semantics under both, and so do mount and XeerProvider. Your source
does not change with the provider; the JSX runtime, the types your editor resolves, the dev server's
Fast Refresh, and the production bundle all do. One physical renderer is in the bundle either way, and
the build artifact records which one. Switching an existing app also means updating the paths table
in tsconfig.json — the one part of that file that differs between providers, and one no command
rewrites for you: copy the block from a xeer new --ui <provider> scaffold, or your editor goes on
reporting against the renderer you switched away from.
Three things are renderer-native rather than shared, and are stated rather than papered over.
TargetedSubmitEvent is a native SubmitEvent under Preact and React's synthetic FormEvent under
React: a handler annotated (event: TargetedSubmitEvent<HTMLFormElement>) => … type-checks and
behaves identically at the call site — preventDefault(), currentTarget — but a handler that
reaches into native-only members such as submitter is provider-specific. children is typed
ComponentChildren or ReactNode to match the renderer. And mount uses createRoot under React,
so the first paint is scheduled rather than synchronous.
#Renderer-neutral core
@impetik/xeer/client is the adapter for the renderer your manifest selected, and the normal import
for an application. Underneath it,
@impetik/xeer/client/core holds transport, query and auth state, invalidation, live-update
subscriptions, and routing as renderer-free stores, for adapter authors and headless
integrations. Its existence does
not make an unshipped renderer supported: a provider counts only once Xeer ships its adapter, compiler
policy, and scaffolds too.
Every browser client on the same canonical origin shares one invalidation domain: learned query
reads, one live stream, one BroadcastChannel, and direct same-document mutation fanout. Paths,
trailing slashes, host case, and default ports do not split it, and releasing the last client closes
both transports and discards the learned reads. Non-browser clients are private by default; an adapter
host that deliberately shares a lifecycle passes the same createInvalidationRegistry() result.
The first client to create a domain supplies that domain's live-stream fetch and
createBroadcastChannel. RPC calls still use each client's own fetch, so dispose every client before
changing those seams.
#useQuery
function useQuery(name, input): {
data: Output | undefined;
error: Error | undefined;
loading: boolean;
refetch(): void;
}The operation name and both its input and output types come from the server you wrote — misspell the
name and your editor tells you, pass the wrong input shape and it tells you, and notes.data is typed
as your handler's return value.
input is a required second argument; pass {} for an input-less query. data is undefined on the
first render and while an error is outstanding. refetch() is synchronous and returns nothing — it
schedules a refetch rather than handing you a promise.
Changing the operation name or input selects a different query resource. That new resource starts
with data: undefined and loading: true; data from the previous key is not carried across the
transition. Keep previous data in component state explicitly if the interface should display it.
You will rarely need refetch, because queries refresh themselves. See Live
updates.
#useMutation
function useMutation(name): (input) => Promise<Output>;It returns a callable, not an object with a .mutate:
const createNote = useMutation('notes.create');
const removeNote = useMutation('notes.remove');
await createNote({ text: 'Water the plants' }); // resolves to the created row
await removeNote({ id: note.id });On success it invalidates the queries that read whatever tables the mutation wrote, so your own UI updates without a round trip and without a cache key to name.
On refusal it rejects with a XeerRuntimeError carrying code, status, id, and message. Handle
it as you would any rejection:
const [problem, setProblem] = useState<string | null>(null);
const add = async () => {
try {
await createNote({ text });
setProblem(null);
} catch (error) {
setProblem(error instanceof Error ? error.message : 'Something went wrong.');
}
};#useAuth
function useAuth(): {
readonly auth: AuthIdentity | null;
readonly loading: boolean;
readonly error: Error | null;
readonly isGuest: boolean;
readonly isAuthenticated: boolean;
refresh(): Promise<void>;
};auth is the same identity your server sees in ctx.auth: appUserId, kind, roles, permissions,
workspaceIds. It is null until the first check resolves, which is what loading is for.
const { auth, loading, isAuthenticated } = useAuth();
if (loading) return <p>Checking your identity…</p>;
if (!isAuthenticated) return <SignInButton />;
return <p>Signed in as {auth.appUserId}</p>;isGuest is true while auth is still null, so it means "not known to be a signed-in user" rather
than "definitely a visitor". Gate on isAuthenticated when you want the stricter reading.
Never use client-side identity as authorization. It decides what to render; the server decides what is allowed. See Auth.
#signIn and signOut
function signIn(options?: {
baseUrl?: string;
returnTo?: string;
provider?: 'google' | 'password';
persona?: 'alice' | 'bob' | 'carol'; // local development only
workspaceIds?: readonly string[]; // local development only
roles?: readonly string[]; // local development only
permissions?: readonly string[]; // local development only
}): void;
function signOut(options?: { baseUrl?: string }): Promise<void>;signIn is a full-page navigation, so nothing after it in the same function runs. signOut is a
request; it clears the session and invalidates queries only in the target application scope, but it
does not refresh useAuth by itself:
const { refresh } = useAuth();
<button onClick={() => void signOut().then(() => refresh())}>Sign out</button>persona, workspaceIds, roles, and permissions switch between local test identities under xeer dev. A
deployed app ignores them and begins real sign-in on the same route, so the button you write for
development is the button that works in production.
The password provider currently works only with the Preact adapter. The React adapter accepts Google sign-in but does not forward the password provider option.
#Ready-made auth components
For the common cases, so you do not have to build a sign-in flow to see one working:
import { SignInButton, UserButton } from '@impetik/xeer/client';SignInButtonstarts sign-in. It takeschildren,className,returnTo, andproviderunder Preact.UserButtonshows the signed-in state and signs out on click. It renders aSignInButtonwhen there is nobody signed in.
Both are plain components you can style or replace. Nothing else in the framework depends on them.
#Custom account management
Do not use AuthManagementPanel, useAuthManagement, or authManagementAction in this release. Their
client model does not match the privacy-preserving app response, which omits provider email and some
cross-app fields. This is a known SDK defect.
#useXeerClient and scoped invalidation
const client = useXeerClient();
client.invalidate(['links']);useXeerClient() returns the contextual client. client.invalidate() reaches every active query in
that client's canonical application scope and no other scope. Use it when reusable UI may be mounted
under a provider targeting another application.
#invalidateQueries
function invalidateQueries(tables?: readonly InvalidatedTable[]): void;Refetch everything, or only the queries known to read one of the named tables, across every active
application domain in the browser document. The names are declared tables, not operation names,
and they are checked against your manifest: invalidateQueries(['links.list']) is XE1205, not a
silent no-op. This compatibility facade remains useful when endpoint
write code runs outside a provider:
const shorten = async (url: string) => {
await fetch('/api/links', { method: 'POST', body: JSON.stringify({ url }) });
invalidateQueries(['links']); // endpoint writes are not tracked
};Mutations do this for you. See Live updates.
#mount and XeerProvider
You do not normally call either. Xeer generates the entry that mounts your component, and it derives one argument from your manifest:
function mount(App: ComponentType, options: { element?: Element | null; live: boolean }): void;live says whether this app opens the live-update stream. It is required
and has no default — the generated entry passes budgets.liveConnections > 0, read from your
normalized manifest, so the client and the server can never disagree about whether push is on.
There is deliberately no default value. A default of false would silently disable push for an app
that had explicitly declared a positive liveConnections, which is the one case that must keep
working; a default of true would reintroduce the retry loop this option exists to remove. If you
call mount yourself, pass the flag:
mount(App, { live: true }); // was: mount(App)XeerProvider takes the same required live prop, for the same reason.
#Renderer hooks
useState is re-exported from @impetik/xeer/client for convenience. Every other hook comes from the
renderer your app selected, and react is a spelling both providers admit — it resolves to
react under "provider": "react", and to preact/compat under the default "preact". Import hooks
from react in any file that has to work under either provider:
import { useEffect, useMemo, useRef } from 'react';Several other React-family names are admitted under both the same way — react-dom,
react-dom/client, react-dom/server, react/jsx-runtime, react/jsx-dev-runtime and
scheduler. Portability is per entrypoint rather than per package, though: react/compiler-runtime
and react-dom/profiling are React-only and are XE1202 under "preact", and the host-runtime
builds react-dom/server.node, react-dom/server.bun and react-dom/server.edge are refused under
either provider, since the client zone is none of those runtimes.
preact/hooks is the direct route for a Preact-only app; it is refused with XE1202 under
"provider": "react".
That recommendation is about new code and code that has to stay portable. The platform's own client
surface does not load preact/compat — @impetik/xeer/client imports preact/hooks, and the JSX
runtime is preact/jsx-runtime — so in an app whose own code has never named react, the first
import that does pulls compat in, and compat is not inert. It installs vnode and event hooks that
rewrite DOM event props app-wide, in components you never touched: onChange becomes an input
event — firing per keystroke instead of on commit — on textareas and on every input type but
checkbox, radio, and file, and onFocus, onBlur, and onDoubleClick become focusin, focusout,
and dblclick. It also adds roughly 10 KB minified, the unit the bundle budget is measured in, to a
bundle that had no compat in it. Those rewrites are React's own semantics, and that convergence is
the point: it is what makes one react-importing file behave the same under both providers. But it
is still a behavior change for the Preact code already around it, so convert an existing app
deliberately rather than one file at a time.
This is the one place a source file names the renderer, which is why the platform hooks are
re-exported from @impetik/xeer/client: code that only imports the facade is the same under both.
#Routing
Set "spa": true under app in your manifest and the platform serves your shell for every
non-asset, non-/api path, so a deep link arrives in the browser instead of 404ing. createRouter
is the other half: it matches the path against a route table, owns the History API, and turns
same-origin <a> clicks into navigations.
import { createRouter, useRoute, type RouteDefinition } from '@impetik/xeer/client';
const ROUTES: readonly RouteDefinition<'index' | 'post' | 'comments'>[] = [
{ name: 'index', pattern: '/', title: 'Blog' },
{ name: 'post', pattern: '/posts/:slug', title: 'Post' },
// A nested route renders inside its parent's chrome, under its own URL.
{ name: 'comments', pattern: '/posts/:slug/comments', title: 'Comments', parent: 'post' },
];
const router = createRouter(ROUTES);
export default function App() {
const { match } = useRoute(router);
if (match === null) return <NotFound />;
if (match.name === 'post') return <Post slug={match.params['slug']} />;
return <Index />;
}Create the router once, next to the table, not inside a component: it is a store, and a component
that made its own would install a second set of listeners on every mount. Pages that navigate
without a link import it and call router.navigate('/').
Patterns are literal segments and :name parameters, matched whole — /posts/:slug matches
/posts/hello and not /posts/hello/comments. The first route in declaration order wins, so put a
literal that overlaps a parameter (/w/new before /w/:workspace) first. Parameters arrive
URI-decoded in match.params, and the query string is match.search, a URLSearchParams, which
never affects matching.
Nesting is a parent name rather than an outlet component. match.chain is the matched route
preceded by every ancestor, root first, and your page switch composes them — render the parent page
with the child page as its children.
Links need no component. Any same-origin <a href="/posts/hello"> becomes a pushState
navigation, while middle-click, modifier-click and right-click keep working as real links because
they are not intercepted. An anchor opts out with download, target, rel="external" or
data-native-link, and replaces the current history entry instead of pushing with data-replace.
Pass interceptLinks: false to turn the whole behavior off.
Titles and scroll are managed for you: document.title becomes the matched route's title, and
a new entry starts at the top while back and forward restore where you were. Compose a fuller title
with documentTitle, which receives the whole snapshot:
createRouter(ROUTES, {
documentTitle: ({ match }) => (match ? `${match.route.title} · Blog` : 'Not found · Blog'),
});Both are properties of the document rather than of a router, so if you run two routers over one
document — the same location matched against different tables — give the second one
manageScroll: false and a documentTitle returning undefined. Location tracking itself needs no
such arrangement: routers hear each other's navigations and stay in agreement.
A missing route is match === null, and your application renders whatever it wants there. It
cannot answer with a 404 status: the shell was already served with a 200, which is what spa
does, and the server never saw a route table.
Matching without a router. matchRoute(routes, url) is the pure function underneath, usable in
tests and on any string. The router itself is renderer-free and lives in
@impetik/xeer/client/core; useRoute is the renderer binding over it and
is the only part of routing that knows what a component is. Each provider implements that one hook —
the route table, the matching, and the history integration are shared.
#What client code may import
The compiler compiles your client and server as separate graphs and enforces the boundary:
| Allowed in client code | Refused |
|---|---|
@impetik/xeer/client, @impetik/xeer/client/core, @impetik/xeer/shared | @impetik/xeer/server — fails with XE1202 |
the selected renderer and its runtime entrypoints — under Preact: preact, preact/hooks, preact/compat, preact/debug, …; under React: react, react-dom, react-dom/client, scheduler | your src/server.ts — fails with XE1201 |
under Preact only: react, react-dom, scheduler — aliases onto the platform's own Preact | preact under React — the renderer your app did not select, XE1202. Under Preact there is no mirror image of this: the React names are the aliases in the cell to the left. |
any npm package declared in your dependencies and installed | an npm package your package.json does not declare in dependencies — XE1207, or a declared package that is not installed — XE1208 |
| your own modules with no server imports | Node builtins and native addons, even deep inside a dependency's own imports — XE1502 at build |
This is why a value read from ctx.env cannot reach a browser bundle by accident: there is no import
path from the client graph to the code that can read it.
#npm dependencies
Add a package to dependencies, install it, and import it — that is the whole rule, and it is the
same rule in server code. A markdown renderer, an HTML escaper, a date library, a validator: import
it from src/server.ts exactly as you would from a component. Type-only imports may come from
devDependencies instead.
The two things a dependency cannot do, it cannot do in either zone: reach a Node builtin or a native
addon (a browser has none to offer and neither does workerd, so the build refuses the bundle rather
than shipping one that throws at load time — XE1502, naming the zone), or smuggle in a second
renderer. The renderer itself is client-only: preact, react, react-dom, and scheduler are
XE1202 in server code even when installed, because the server has nothing to render into.
Everything below about declaring, typing, and bundling a dependency applies to both zones. Whichever bundle consumes a package, its bytes are hashed into the artifact and receipted per file, so the same source and the same installed tree always produce the same artifact ID.
#Dependencies without type declarations
Declaring and installing a package satisfies the import policy; it still has to type-check. A
package that ships its own .d.ts files, or that has a published @types/… companion you add to
devDependencies, works immediately. A package with neither fails xeer check with XE1205 and
TypeScript's TS7016 — could not find a declaration file.
Try the types first. DefinitelyTyped publishes under a name derived from the package's: slug-case
is @types/slug-case, and a scoped package flattens its scope rather than keeping it, so
@acme/widget is @types/acme__widget. The diagnostic's hint names it for you.
When the package genuinely has none, acknowledge it in the manifest:
{
"untypedDependencies": ["slug-case"]
}Xeer generates the ambient declarations from that one entry — the package and every subpath under
it, so import { shout } from 'slug-case/extra.js' needs nothing added. They are written to
.xeer/generated/, which your tsconfig.json already includes, so the editor and xeer check agree
about the package without any further setup. Withdraw the acknowledgment and the declarations go with
it.
The acknowledgment types the package as any, which is a real hole in a guarantee the rest of the
platform makes, so it stays on the record: every check reports XE1214 naming each acknowledged
package, at info severity.
Acknowledging a package that does have declarations is XE1213, a warning, and the entry is
ignored rather than applied. That is not fussiness — an ambient declaration overrides module
resolution, so applying it would replace the package's real types with any, and a call its own
signature refuses would compile clean. The real declarations keep answering; the warning tells you
the line does nothing. XE1212 is the neighbouring case: an acknowledged package that
package.json never declared as a dependency.
You can still hand-write a declaration, and it is the better answer when you want real types rather
than any — the acknowledgment can only ever give you any:
// src/types/slug-case.d.ts
declare module 'slug-case' {
export function slug(value: string): string;
}/// <reference path="./types/slug-case.d.ts" />
import { slug } from 'slug-case';Both halves are required there. The compiler builds its TypeScript program from the files your entrypoints import, so a declaration file nothing points at is never read and the error does not move — the triple-slash reference is what admits it. Put the reference on the first line of the file that imports the package; the declaration is program-global once read, so one reference covers every importer. A declaration is type-space only either way: it joins no module graph, adds no bundle input, and produces no asset.
A React-ecosystem library works as-is under either provider, by two different routes: under Preact its
react, react-dom, and scheduler imports resolve to the platform's own preact/compat, and under
React they resolve to the platform's own React. Either way your components and the library's render
through one renderer instance. Always import the renderer by its bare specifier — a relative or
absolute path into some node_modules copy would mean a second physical renderer in the bundle, and
xeer check refuses it at the import with XE1503 — as does the build, if it ever gets that far.
Test-only entrypoints such as preact/test-utils and react-dom/test-utils stay out
of application code.
Your client bundle also has a size budget: 1 MiB of minified JavaScript by default, raisable in the
manifest with budgets.clientBundleBytes. Past the budget the build
fails. Before that, a fixed 400 KiB advisory tier warns — measured over your application's own share,
which is the bundle minus the renderer the platform put in it and your code cannot remove. Both carry
a per-package breakdown of where the bytes came from.
Dependencies that ship fonts, images, or WebAssembly work too: a url() in a package stylesheet or
a file import in its code becomes a hashed asset served from an immutable URL, and the previous
deploys' assets keep serving through the changeover, so a page cached across a deploy never loses
its fonts.
#Using React libraries
Install and import. There is no configuration step and no adapter:
pnpm add zustandimport { create } from 'zustand';
interface Filter { query: string; setQuery: (value: string) => void }
const useFilter = create<Filter>((set) => ({
query: '',
setQuery: (query) => { set({ query }); },
}));
export function SearchBox() {
const { query, setQuery } = useFilter();
return <input value={query} onInput={(event) => setQuery(event.currentTarget.value)} />;
}Never add react, react-dom, scheduler, or preact to your own dependencies. The renderer
is platform-sourced: every one of those specifiers — in your code and inside every library you
install — is pinned to the platform's copy, so a declaration of your own changes nothing about what
the bundle contains. xeer check warns about it (XE1210) rather than failing, so an app that
already has one keeps building, but the declaration is worth removing: it gives your package manager
a second copy of React to reconcile, which is how an unrelated install weeks later starts failing to
resolve.
That pin is also why a library's peerDependencies: { "react": "^18 || ^19" } needs nothing from
you. Two things your package manager may still say about it:
- pnpm may warn about an unmet peer. It is describing your
node_modules, not your bundle: it cannot see that the platform satisfies the peer. The build is unaffected. - npm may reshuffle
node_modules. Installing a library whose peer range is older can leave an older React at your project root. The artifact still ships the platform's copy — one React, the one your code and your libraries both render through — andxeer checknow names both versions (XE1211) so the number you read innode_modulesis never quietly different from the number your users run.
A library that ships a stylesheet is supported the same way:
import 'react-day-picker/style.css';It folds into the single stylesheet the application serves, alongside your own CSS, and any font or
image its url() rules name becomes a hashed asset like any other.
Under the Preact provider all of this works too, by a different route: react, react-dom, and
scheduler are aliases onto the platform's preact/compat, so the same library resolves onto Preact
and your bundle stays Preact-sized. The application source does not change between providers.
#Styling and assets
Import CSS from your client and it is bundled:
import './styles.css';Anything in public/ is served from the root of your app. There is no CSS framework, no component
library, and no opinion about how your app should look.
#Next
- Live updates — why the list above refreshes itself.
- Server functions and the database — the other half of every call here.
- Auth — what
useAuthis reading, and how sign-in works. - Local development — switching identity while you develop.