Skip to content
AbloAblo Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 secret apiKey (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 via session.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

Was this page helpful?