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)
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 viaauthEndpoint.
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',
});
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 { ablo } from '@/lib/ablo';
import { getCurrentUser } from '@/auth';
import { headers } from 'next/headers';
import {
credentialEndpointErrorSchema,
credentialEndpointSuccessSchema,
} from '@abloatai/ablo/auth';
const noStore = { 'Cache-Control': 'no-store' };
export async function POST(request: Request) {
if (!(await isSameOrigin(request))) {
return Response.json(
credentialEndpointErrorSchema.parse({
error: { code: 'origin_mismatch', message: 'Cross-origin mint rejected' },
}),
{ status: 403, headers: noStore },
);
}
const user = await getCurrentUser();
if (!user) {
return Response.json(
credentialEndpointErrorSchema.parse({
error: { code: 'session_expired' },
}),
{ status: 401, headers: noStore },
);
}
// Query your membership table now—not when the login session was created.
// The helper reads the active workspace from server-side session state and
// returns null when the membership is stale or revoked.
const scope = await authorizeActiveWorkspace(user.id);
if (!scope) {
return Response.json(
credentialEndpointErrorSchema.parse({
error: { code: 'policy_denied', message: 'Workspace membership is stale or revoked' },
}),
{ status: 403, headers: noStore },
);
}
const { token, expiresAt } = await ablo.sessions.create({
user: { id: user.id },
syncGroups: scope.syncGroups,
can: { records: ['read', 'create', 'update'] },
});
return Response.json(
credentialEndpointSuccessSchema.parse({
token,
expiresAt,
credentialKind: 'ephemeral',
}),
{ headers: noStore },
);
}
async function isSameOrigin(request: Request): Promise<boolean> {
const origin = request.headers.get('origin');
if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site';
const host = (await headers()).get('host');
return host !== null && new URL(origin).host === host;
}
authorizeActiveWorkspace is application code: it must query the authoritative
membership store and return server-derived sync 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 authEndpoint 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';
import { AbloProvider } from '@abloatai/ablo/react';
import { schema } from '@/lib/ablo.schema';
const ablo = Ablo({
schema,
authEndpoint: '/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.get({ 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.