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/sdkCore 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:
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:
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:
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:
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:
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: booleanPost-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.1ascending,-1descending.$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.