Skip to content
Reference

Next.js SDK

Type-safe client for building applications on LeftFold. Headless — provides data, not components. Works with any JavaScript runtime, with first-class Next.js App Router support.

Overview

The SDK is published as @leftfold/sdk and follows the pattern established by @supabase/supabase-js and @sanity/client: a headless typed client at the core, with framework-specific helpers layered on top.

Three entry points:

  • @leftfold/sdk — core client, works anywhere (Node, Bun, Deno, browser)
  • @leftfold/sdk/next — Next.js App Router helpers (server client, provider, hooks, middleware)
  • @leftfold/sdk/types — generated TypeScript types for all registered aggregates

Install

bun add @leftfold/sdk

Core Client

The core client works in any JavaScript runtime. Types for seeded aggregates are bundled; dynamic aggregate types are generated at build time or introspected at runtime.

import { createClient } from "@leftfold/sdk";

const leftfold = createClient({
  workspace: "acme",
  apiKey: process.env.LEFTFOLD_API_KEY,
});

// Typed query — returns Article[]
const articles = await leftfold.articles.list({
  status: "published",
  limit: 10,
});

// Typed get — returns Article
const article = await leftfold.articles.get(articleId);

// Typed mutation — accepts ArticleCreateInput
await leftfold.articles.create({
  title: "ACL Recovery Timeline",
  body: "...",
  slug: "acl-recovery-timeline",
});

Each aggregate type on the client (leftfold.articles, leftfold.images, leftfold.people) provides .list(), .get(), .create(), .update(), and type-specific methods like .publish() for articles.

By default the SDK targets https://leftfold.io/w/{workspace}/api/v1. For local development, pass baseUrl pointing to your local Supabase edge functions:

Local development
const leftfold = createClient({
  workspace: "acme",
  apiKey: process.env.LEFTFOLD_API_KEY,
  baseUrl: "http://127.0.0.1:54321/functions/v1/http-api",
});

Next.js Setup

Provider (client-side)

Wrap your app with the provider to make the LeftFold client available via React context:

app/providers.tsx
import { LeftFoldProvider } from "@leftfold/sdk/next";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <LeftFoldProvider
      workspace="acme"
      supabaseUrl={process.env.NEXT_PUBLIC_SUPABASE_URL!}
      supabaseAnonKey={process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!}
    >
      {children}
    </LeftFoldProvider>
  );
}

Server client

Create a server-side client factory that reads the session from cookies:

lib/leftfold-server.ts
import { createServerClient } from "@leftfold/sdk/next";
import { cookies } from "next/headers";

export function getLeftFoldClient() {
  return createServerClient({
    workspace: "acme",
    supabaseUrl: process.env.SUPABASE_URL!,
    supabaseServiceKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
    cookies: cookies(),
  });
}

Middleware

The middleware refreshes the Supabase Auth session on every request and optionally protects routes:

middleware.ts
import { createMiddleware } from "@leftfold/sdk/next";

export const middleware = createMiddleware({
  workspace: "acme",
  supabaseUrl: process.env.SUPABASE_URL!,
  supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
});

Server Components

For Server Components (the default in App Router), use the server client directly. No client-side JavaScript is shipped:

app/articles/page.tsx
import { getLeftFoldClient } from "@/lib/leftfold-server";

export default async function ArticlesPage() {
  const leftfold = getLeftFoldClient();
  const articles = await leftfold.articles.list({
    status: "published",
    limit: 20,
  });

  return (
    <ul>
      {articles.map((a) => (
        <li key={a.aggregate_id}>{a.title}</li>
      ))}
    </ul>
  );
}

This is the recommended pattern for content delivery pages where SEO and initial load performance matter.


React Hooks

Client components use hooks that follow the use<Resource> pattern. They use the context established by LeftFoldProvider and handle caching, revalidation, and error states.

"use client";

import { useArticles, useArticle, useMailbox } from "@leftfold/sdk/next";

function ArticleList() {
  const { data, isLoading } = useArticles({ status: "published", limit: 10 });
  if (isLoading) return <p>Loading...</p>;
  return (
    <ul>
      {data?.map((a) => <li key={a.aggregate_id}>{a.title}</li>)}
    </ul>
  );
}

function MailboxCounter() {
  const { data } = useMailbox({ status: "submitted" });
  return <span>{data?.length ?? 0} submitted</span>;
}

Pipeline Queries

Queries are MongoDB-style pipelines — arrays of stage objects processed in order. The first stage must be a $match with aggregate_type. The $fold stage replays events per instance to produce current state. Results are cached and auto-invalidated on write.

// Get all articles, newest first
const articles = await leftfold.query([
  { $match: { aggregate_type: "article" } },
  { $fold: {} },
  { $sort: { _last_event_at: -1 } },
  { $limit: 25 },
]);
// articles.result: Record<string, unknown>[]
// articles.count: number
// articles.cached: boolean

Post-fold filtering

A second $match after $fold filters on the projected state, not the raw events:

const published = await leftfold.query([
  { $match: { aggregate_type: "article" } },
  { $fold: {} },
  { $match: { status: "published" } },
]);

Cross-aggregate joins

$lookup joins across aggregate types. The target is auto-folded:

const articlesWithAuthors = await leftfold.query([
  { $match: { aggregate_type: "article" } },
  { $fold: {} },
  { $lookup: {
    from: "person",
    localField: "author_id",
    foreignField: "_instance",
    as: "author",
  }},
]);

Group and aggregate

$group supports accumulators: $sum, $count, $avg, $min, $max, $push.

const statusCounts = await leftfold.query([
  { $match: { aggregate_type: "article" } },
  { $fold: {} },
  { $group: { _id: "$status", count: { $count: {} } } },
  { $sort: { count: -1 } },
]);

Available pipeline stages

  • $match — filter events (pre-fold) or documents (post-fold). Supports $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists.
  • $fold — replay events per instance into current state via last-write-wins merge.
  • $lookup — join across aggregates. Target is auto-folded.
  • $group — aggregate with accumulators.
  • $project — select, exclude, or rename fields. Supports dot-notation.
  • $sort — order results. 1 ascending, -1 descending.
  • $limit — cap result count.

Typed Helpers

Typed helpers wrap the pipeline for common operations. They use query() under the hood:

// These are equivalent:
const a = await leftfold.articles.list({ limit: 10 });
const b = await leftfold.query([
  { $match: { aggregate_type: "article" } },
  { $fold: {} },
  { $sort: { _last_event_at: -1 } },
  { $limit: 10 },
]);

Semantic search

const results = await leftfold.aggregates.search("ACL rehabilitation exercises", {
  type: "article",
  limit: 5,
});

Mutations

Mutations return the updated projection and the event metadata:

const { data, event } = await leftfold.articles.create({
  title: "New Article",
  body: "...",
  slug: "new-article",
});

// data: Article projection (folded state)
// event: { event_id, sequence_number, created_at }

Realtime Subscriptions

The SDK wraps Supabase Realtime for live updates to projections:

"use client";

import { useSubscription } from "@leftfold/sdk/next";

function MailboxBadge() {
  const { count } = useSubscription("mailbox", {
    filter: { status: "submitted" },
    select: "count",
  });
  return <span>{count}</span>;
}

Subscriptions are scoped by workspace via RLS and can be filtered by project, aggregate type, and status. The hook manages connection lifecycle, reconnection, and cleanup.


Generic Client

For dynamic aggregate types or advanced use cases, the generic client provides untyped access to any aggregate:

const treatments = await leftfold.aggregates.list({ type: "treatment" });

await leftfold.aggregates.append(aggregateId, {
  event: "treatment.updated",
  payload: { duration_minutes: 45 },
});

This is useful for aggregates created via event storming that don't have pre-generated types.