SynapDocs

Build a website on your pod

Pick how your site talks to a pod, then build a Next.js app that people connect to their own pod, reading and writing their records with the SDK.

For BuildersSDKOpen-source podSynap CloudReviewed 2026-11-03

A pod is a backend: a website or an app can read and write its records through the same doors, permissions and governance as the Synap apps.

Pick how your site connects

Your siteUseCredential
Shows what you published from your pod (a share, a form, a project published as a site) to anyone@synap-core/sdk/publicNone: the public token of what you published
Lets each visitor connect their own pod and work on their records@synap-core/sdk/connect (this tutorial)The visitor's consent; the token stays on your server
Runs on your own server against your own pod (a portfolio, an internal tool)@synap-core/hub-rest-clientAn API key kept on the server
Runs where the person is already signed in to the pod (same domain)The typed client in @synap-core/sdkThe person's sign-in session

Never put an API key in code that runs in the browser.

Build an app people connect to their pod

You will build a small notes app. A visitor enters their pod's address; their pod shows a consent screen that lists what the app asks for and lets them choose which spaces it may use; the app then lists their notes and creates new ones.

@synap-core/sdk/connect handles the sign-in for you: the OAuth flow with PKCE, the checks on the pod's answers, the token kept in a sealed, HTTP-only cookie on your server, and its renewal. It is server-only and throws if it is loaded in a browser. It uses Web-standard Request and Response, so it works in Next.js route handlers and other frameworks; this tutorial uses the Next.js App Router.

What you need

  • A Next.js 16 app (Next.js 16 names its middleware proxy.ts).
  • An https address for it, for example https://notes.example.com. A real pod only accepts an app served over https; in development, use a tunnel.
  • A pod to connect to, hosted or self-hosted.

Install the SDK and set two variables

npm install @synap-core/sdk
.env.local
SYNAP_APP_URL=https://notes.example.com
SYNAP_APP_SESSION_SECRET=<at least 32 random characters>

SYNAP_APP_URL is your app's origin: no path, no trailing slash, a domain name rather than an IP address. SYNAP_APP_SESSION_SECRET seals the cookie that holds the connection. Keep both server-side, never in a NEXT_PUBLIC_ variable.

Describe your app

lib/synap.ts
import { createPodAppAuth } from "@synap-core/sdk/connect";

export const synap = createPodAppAuth({
  appUrl: process.env.SYNAP_APP_URL!,
  clientName: "My notes",
  requests: [
    { permission: "entity.note.read", reason: "Show your notes" },
    { permission: "entity.note.create", reason: "Save the notes you write here" },
  ],
  secret: process.env.SYNAP_APP_SESSION_SECRET!,
});

Each request is one permission, written <subject>.<kind>.<action> as in API key grants, with the sentence the consent screen shows. Ask for exactly what the app does: wildcards are refused, and the person sees every line. The SDK refuses at startup what the pod would refuse at sign-in.

Mount the sign-in routes

Each route file re-exports one handler:

app/.well-known/synap-client.json/route.ts
import { synap } from "@/lib/synap";
export const GET = synap.handlers.clientMetadata;
app/api/synap/connect/route.ts
import { synap } from "@/lib/synap";
export const POST = synap.handlers.connect;
app/api/synap/callback/route.ts
import { synap } from "@/lib/synap";
export const GET = synap.handlers.callback;
app/api/synap/disconnect/route.ts
import { synap } from "@/lib/synap";
export const POST = synap.handlers.disconnect;

/.well-known/synap-client.json describes your app to the pod. Its URL is your app's client ID, so there is nothing to register on the pod beforehand. connect and disconnect accept only a POST from your own pages.

Renew the connection before each page

A server component cannot set cookies, so the connection is renewed in proxy.ts, before the page renders:

proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { synap } from "@/lib/synap";

export async function proxy(request: NextRequest) {
  const outcome = await synap.refreshIfNeeded(request);
  const headers = new Headers(request.headers);
  if (outcome.cookieHeader !== null) headers.set("cookie", outcome.cookieHeader);
  const response = NextResponse.next({ request: { headers } });
  for (const cookie of outcome.setCookies) response.headers.append("Set-Cookie", cookie);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!_next/static|_next/image|api/synap/|\\.well-known/|.*\\.\\w+$).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};

refreshIfNeeded renews the token only in its last five minutes. Forwarding cookieHeader lets the page see the renewed token in the same request. The matcher skips static files, the sign-in routes and router prefetches.

Add a connect page

app/connect/page.tsx
export default function ConnectPage() {
  return (
    <form method="post" action="/api/synap/connect">
      <label>
        Your pod address <input name="pod" placeholder="pod.example.com" required />
      </label>
      <button type="submit">Connect</button>
    </form>
  );
}

The form posts the pod's address (https:// is added when it is missing). The visitor goes to their pod, approves, and comes back to /. A sign-in that fails comes back with an error code in the query: on /connect when it could not start, on /connect/result when it failed on the way back.

Read the visitor's notes

app/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { synap } from "@/lib/synap";
import { addNote } from "./actions";

export default async function Home() {
  const connection = await synap.readConnection((await headers()).get("cookie"));
  if (!connection) redirect("/connect");

  const pod = synap.client(connection);
  const notes = await pod.listEntities({ profileSlug: "note", limit: 50 });
  if (!notes.ok) return <p>Could not read your notes ({notes.error.code}).</p>;

  return (
    <main>
      <form action={addNote}>
        <input name="title" required />
        <button type="submit">Add note</button>
      </form>
      <ul>
        {notes.data.map((note) => (
          <li key={note.id}>{note.title}</li>
        ))}
      </ul>
    </main>
  );
}

Calls return a result instead of throwing: { ok: true, data }, or { ok: false, error } whose error.code says what happened, such as expired (connect again), forbidden (the person did not grant it), rate_limited or pod_unreachable. The app sees only the spaces the person chose; without workspaceId, listEntities reads all of them.

Write, and expect a proposal

app/actions.ts
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { synap } from "@/lib/synap";

export async function addNote(form: FormData) {
  const connection = await synap.readConnection((await headers()).get("cookie"));
  if (!connection) redirect("/connect");

  const pod = synap.client(connection);
  const space = pod.spaces()[0]; // a space the person granted
  if (!space) redirect("/connect");

  const result = await pod.post("/entities", {
    profileSlug: "note",
    title: String(form.get("title")),
    workspaceId: space.workspaceId,
  });

  if (!result.ok) redirect(`/?failed=${result.error.code}`);
  if (result.status === "proposed") redirect(`/?review=${encodeURIComponent(result.reviewUrl)}`);
  redirect("/?saved=1");
}

Every create names a space the person granted. A connected app acts as its own agent, one that asks first, so its writes come back as status: "proposed" with a reviewUrl, and the person approves them in their pod. That is a success: show "Sent to your pod for review" with the link, and never retry it. (pod.actsAs is "agent" for every app except Synap's official ones, whose writes apply directly.)

Test without a pod

@synap-core/sdk/testing has a mock pod, createMockAuthServer(), for development and tests. With allowInsecureLoopback: true, createPodAppAuth accepts it on http://localhost; a real pod, even a local one, still needs your app on https.

Going further

The connection client has typed reads beyond listEntities: getEntity, getEntityConnections, getDocument, getFileUrl (a signed link to a file) and listPendingProposals (what waits for the person's review; needs proposal.read). spaces() and can(permission, workspaceId) let a page adapt to what was granted. pod.actions asks the person's own tools to do something for them, such as publishing a post, after they approve it (needs capability.<intent>.run). The AGENTS.md file shipped in the npm package documents every rule.

A public site

To show what you published from your pod, with no sign-in at all:

import { createPublicClient } from "@synap-core/sdk/public";

const pod = createPublicClient({ podUrl: "https://pod.example.com" });
const page = await pod.listCollection("<site token>", "<collection key>");
if (page.ok) console.log(page.data);

The client reads a public share (readShare), a form (getForm, submitForm), or a project you published as a site (getSite, listCollection, getRecord). Publishing a site creates its token, shown once to the person who publishes it. The same entry has helpers that turn what it reads into safe page models: render pod content through them, never as raw HTML.

On this page