Next.js Example
App-router setup: the two clients, the session route, and reactive reads.
A production-shaped Next.js app on Ablo — App Router, Server Actions, React Server Components, and live client subscriptions. It handles three things at once: a fast initial render from the server, writes that don’t overwrite work already in progress, and a UI that updates the moment data changes.
The key piece is claim(). Commit a write through it and Ablo rejects the write
if the record moved since you read it, so nothing is silently clobbered. Claims
don’t lock: if another writer holds the row, claim waits for them, re-reads the
fresh row, then hands it to you — writers serialize instead of colliding.
Structure
app/
layout.tsx # wraps the tree in <Providers>
providers.tsx # Client: browser Ablo client + <AbloProvider>
api/
ablo-session/
route.ts # mints a per-user ek_ token for the browser
records/
[id]/
page.tsx # RSC: get + render
actions.ts # Server Action: claim, then write
RecordEditor.tsx # Client: live updates
lib/
ablo.ts # Server Ablo client (holds ABLO_API_KEY)
sessions.ts # Server-only scoped-session issuer
ablo.schema.ts # shared schema
There are two Ablo clients, and the split is the whole point:
- Server (
lib/ablo.ts) holds the secretapiKey(sk_). Used by RSCs, Server Actions, and route handlers. Never imported into a client component. - Browser (
app/providers.tsx) holds no secret. It fetches a short-lived per-user token (ek_) from a backend route viasession.endpoint.
Skipping the browser half is the most common setup mistake — the client then
has no credential and the engine fails to initialize with session_expired.
Server Client
// lib/ablo.ts — server-only
import 'server-only';
import Ablo from '@abloatai/ablo';
import { schema } from './ablo.schema';
export const ablo = Ablo({
schema,
apiKey: process.env.ABLO_API_KEY,
transport: 'http',
});
// lib/sessions.ts — server-only
import 'server-only';
import Sessions from '@abloatai/ablo/sessions';
import { schema } from './ablo.schema';
export const sessions = Sessions({
schema,
apiKey: process.env.ABLO_API_KEY,
});
Session Route
The browser can’t hold sk_, so a backend route mints a scoped, short-lived
ek_ for the signed-in user. Being signed in is not workspace authorization:
revalidate the active membership immediately before every mint, and derive all
organization, workspace, team, and group ids on the server. Never accept them
from the request body.
// app/api/ablo-session/route.ts
import { sessions } from '@/lib/sessions';
import { getCurrentUser } from '@/auth';
export const POST = sessions.handler({
authenticate: () => getCurrentUser(),
async grant({ principal: user }) {
// Query your membership table now—not when the login session was created.
const scope = await authorizeActiveWorkspace(user.id);
if (!scope) return null;
return {
user: { id: user.id },
groups: scope.groups,
can: { records: ['read', 'create', 'update'] },
};
},
});
authorizeActiveWorkspace is application code: it must query the authoritative
membership store and return server-derived groups. If fifteen-minute token
expiry is too slow for your revocation requirements, mint a shorter
ttlSeconds and revoke active sessions when membership changes.
Provider
The browser client points session.endpoint at that route and is handed to
<AbloProvider> as an instance. Build it once at module scope so the socket
isn’t torn down on every render.
// app/providers.tsx
'use client';
import { Ablo } from '@abloatai/ablo/react';
import { AbloProvider } from '@abloatai/ablo/react';
import { schema } from '@/lib/ablo.schema';
const ablo = Ablo({
schema,
session: { endpoint: '/api/ablo-session' },
});
export function Providers({ children }: { children: React.ReactNode }) {
return <AbloProvider client={ablo}>{children}</AbloProvider>;
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
RSC Initial Render
// app/records/[id]/page.tsx
import { ablo } from '@/lib/ablo';
export default async function RecordPage({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params;
await ablo.ready();
const record = await ablo.records.read({ id });
if (!record) return null;
return <RecordEditor record={record} />;
}
Server Action Commit
// app/records/[id]/actions.ts
'use server';
import { ablo } from '@/lib/ablo';
export async function markDone(id: string) {
// Claim grants exclusive, ordered access and hands back the fresh row.
await using claim = await ablo.records.claim({ id });
const record = await ablo.records.update({
id,
data: { status: 'done' },
claim,
});
return { status: 'done', record };
// claim auto-releases as the action returns
}
The write runs while the claim is held. If anything else commits between the read and the write, the commit is rejected because the row changed underneath you — re-fetch and retry.
Live Client
'use client';
import { useAblo } from '@abloatai/ablo/react';
export function RecordEditor({ record: serverTask }: Props) {
const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
const busy = Boolean(holder);
return (
<button disabled={busy || data.status === 'done'}>
{busy ? 'Someone is editing' : 'Mark done'}
</button>
);
}
More
- React reference — every option on
useAblo. - API reference — every option on the write path.