Skip to content
AbloAblo Docs
Esc
navigateopen⌘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)
  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 authEndpoint.

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

Was this page helpful?