Skip to content

Next.js

The example app is a complete Next.js integration: an inbox, a timeline, delegation, admin tools and a demo clock. The code on this page comes from it. Run it with pnpm demo from a clone of the repository.

Create Rungs once and share it. In development, Next.js reloads modules, so keep the instance on globalThis:

rungs/demo.ts
// One instance per server process. Kept on globalThis so hot reload in `next dev` does not open
// the PGlite database a second time.
const state = globalThis as unknown as {
rungsDemoDb?: Promise<PgClient>;
demo?: Promise<Demo>;
};
/** The server process's one database connection (or pool). */
export function sharedDatabase(): Promise<PgClient> {
state.rungsDemoDb ??= openDatabase().catch((error: unknown) => {
state.rungsDemoDb = undefined;
throw error;
});
return state.rungsDemoDb;
}
export function getDemo(): Promise<Demo> {
state.demo ??= (async () => {
const demo = await createDemo(await sharedDatabase());
const { seedIfEmpty } = await import("./seed");
await seedIfEmpty(demo);
return demo;
})().catch((error: unknown) => {
state.demo = undefined;
throw error;
});
return state.demo;
}

From the example app:examples/nextjs-expense/rungs/demo.ts

rungs/demo.ts
/** One Rungs instance per schema. `clock` and `ids` are injectable so the seed can replay the past. */
export function rungsFor(
db: AppDb,
inactive: Set<string>,
clock: () => Date,
ids: () => string = () => newId("r"),
): Rungs {
const rungs: Rungs = createRungs({
store: postgresStore(db, { schema: db.schema }),
clock,
ids,
...resolutionHooks(inactive),
// After every command commits: tell the people involved.
onEvent: async (event) => {
const request = await rungs.get(event.requestId);
if (request !== null) await notify(db, event, request);
},
});
return rungs;
}

From the example app:examples/nextjs-expense/rungs/demo.ts

Rungs runs in the Node.js runtime only: it needs a database connection. Do not import it in Client Components or in the Edge runtime.

Ask Rungs for the approval status and load your own records for the page in one query:

app/expenses/page.tsx
// The approval status comes from Rungs, newest first; the details from the app's own table.
const page = await rungs.requests({
requesterId: user.id,
limit: 20,
...(after === undefined ? {} : { after }),
});
const expenses = await expensesByIds(
db,
page.requests.map((r) => r.subject.id),
);

From the example app:examples/nextjs-expense/app/expenses/page.tsx

Each Server Action runs one command. Take the actor from the session, never from form fields, and send back the version the form was rendered with, so that a stale form gets a conflict message instead of overwriting someone else’s decision:

app/actions.ts
export async function decideAction(
requestId: string,
version: number,
_: ActionState,
form: FormData,
): Promise<ActionState> {
const user = await requireUser();
const tooLong = commentTooLong(text(form, "comment"));
if (tooLong !== undefined) return { error: tooLong };
const { rungs } = await requireDemo();
const input = { requestId, actorId: user.id, expectedVersion: version, ...comment(form) };
switch (text(form, "decision")) {
case "approve":
return attempt(() => rungs.approve(input), "Approved.");
case "reject":
return attempt(() => rungs.reject(input), "Rejected.");
case "return":
return attempt(
() => rungs.return({ ...input, to: "requester" }),
"Returned to the requester.",
);
default:
return { error: "Choose approve, reject or return." };
}
}

From the example app:examples/nextjs-expense/app/actions.ts

The example’s attempt helper turns a RungsError into a message for the form and revalidates the page. Rungs does not check permissions such as who is an administrator; check them in the action, as the example does with requireAdmin().

Server Actions and pages do not run on a schedule. Run tick and event delivery in a separate worker, a cron job or a scheduled route; see Running tick.