Skip to main content

Server-side rendering

On a server render the browser holds the session and the server does not, so the SDK forwards the request's headers under an origin allow list.

Overview

A server render has no session cookie of its own. A server-side createDalpClient either forwards the signed-in user's session from the incoming request with ssr.requestHeaders, or acts as your service with an API key.

Forwarding the session renders what this user is allowed to see. An API key renders what your service is allowed to see. Choosing the wrong one is an authorisation mistake, and on a platform that hosts several organisations it can leak one organisation's data to another.

The choice

Forward the user's session

The render sees exactly what the signed-in user sees. Correct for any page whose content is that user's data: their portfolio, their organisation's assets, their action list. Requires the incoming request's headers.

Use a server API key

The render acts as your service. Correct for content that is the same for everyone: a public asset page, a marketing figure, a scheduled export. Never correct for per-user content, because the key's authority is not the user's. Issue that key with the read scope, so a render credential cannot write.

If two different users who load this page should see different content, forward the session. Rendering per-user content with a service key is how a page shows data meant for one user or organisation to someone else, and it does not surface in testing where everyone shares an account.

Forwarding a session

Pass the incoming request's headers as ssr.requestHeaders and the SDK builds a fetch that forwards the incoming request's whole Cookie header to your instance, with its Origin and tracing headers. Every cookie the browser sent to your app therefore reaches the instance, not only the DALP session.

import { createDalpClient } from "@settlemint/dalp-sdk";

function serverClient(request: Request) {
  return createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: request.headers },
  });
}

Build one per request. A client built once at module scope and shared holds the first request's headers and serves that user's session to everyone who arrives after, which is the same leak as the wrong credential arriving by a different route.

What the forwarding does for you

The SDK limits where a forwarded cookie can go and which writes it can carry.

Destinations are on the allow list. The first request may only go to your instance's origin or to one you name in ssr.allowedOrigins. An unapproved destination throws before the request goes out, so a mistyped base URL fails rather than carrying a session somewhere else. A redirect can still lead a read to another origin, without the session.

Redirects lose credentials on the way out. Redirects are followed manually, up to five. A sixth redirect throws an error with the name DalpSsrSecurityError and the message “DALP SDK: SSR fetch exceeded the redirect limit.” The SDK does not export that class, so match on error.name. As long as each hop stays inside the allow list, the cookie and context headers travel with it. The first hop outside strips them: the cookie, authorisation, the API key, the idempotency key, the tenant and acting-context headers (X-Organization-Id, X-Participant, X-Executor, X-Chain-Id, X-Selected-Signers, X-Network-Id and X-Custody-Account-Id), the Origin and Referer headers, and the tracing headers. The SDK refuses a redirect that would carry a write method outside the allow list, because the write would arrive without its acting context.

Cookie-authenticated writes need a trusted origin. A mutation carrying a forwarded cookie is refused unless the incoming request's Origin is your instance's origin or one you list in ssr.allowedOrigins, and the request does not declare itself cross-site. When your app is served from a different origin than baseUrl, add the app's public origin to ssr.allowedOrigins, or every server action that writes is refused. That is cross-site request forgery protection inside the SDK, and it is why a server action that writes needs the real request headers rather than a hand-built set.

TanStack Start

Build the client inside the server function or loader that has the request, and let the router's dehydration carry the result to the browser.

// app/lib/dalp.server.ts
import { createDalpClient } from "@settlemint/dalp-sdk";
import { getRequest } from "@tanstack/react-start/server";

export function dalpServer() {
  const request = getRequest();
  return createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: request.headers },
  });
}
// app/routes/tokens.tsx
export const Route = createFileRoute("/tokens")({
  loader: async ({ context }) => {
    await context.queryClient.ensureQueryData({
      ...tokenListOptions,
      queryFn: () => dalpServer().api.token.list({ page: { limit: 50 } }),
    });
  },
  component: TokenList,
});

function TokenList() {
  const { data } = useSuspenseQuery(tokenListOptions);
  return <Table rows={data.data} />;
}

The loader and the component share one cache key and use different fetches. On the server, the fetch is the session-forwarding client. In the browser, it is the ordinary browser client. Reusing queryOptions for the key on both sides is what makes the hydrated entry the one the component reads, instead of a second fetch on mount.

Keep the server client out of any module a component imports. A .server.ts suffix or an equivalent rule in your build is what stops a server-only import from being pulled into the browser bundle.

Next.js

Server components

A server component has no request object, so read the headers from next/headers:

// app/tokens/page.tsx
import { headers } from "next/headers";
import { createDalpClient } from "@settlemint/dalp-sdk";

export default async function TokensPage() {
  const dalp = createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: await headers() },
  });

  const tokens = await dalp.api.token.list({ page: { limit: 50 } });
  return <TokenTable rows={tokens.data} />;
}

Calling headers() opts the route out of static rendering, which is correct here: a page rendered from a user's session cannot be a shared static artifact.

Read DALP_API_URL and DALP_API_KEY without the NEXT_PUBLIC_ prefix. A prefixed variable is inlined into the client bundle at build time, and a prefixed API key is a published API key.

Route handlers and server actions

Both have the request, directly or through headers():

// app/api/tokens/route.ts
export async function GET(request: Request) {
  const dalp = createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: request.headers },
  });

  const tokens = await dalp.api.token.list({ page: { limit: 50 } });
  return Response.json(tokens);
}
// app/actions.ts
"use server";

import { headers } from "next/headers";
import { createDalpClient } from "@settlemint/dalp-sdk";

export async function transfer(input: { assetId: string; recipient: string; amount: string }) {
  const dalp = createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: await headers(), allowedOrigins: [process.env.APP_PUBLIC_ORIGIN!] },
  });

  const { assetId, recipient, amount } = input;
  return dalp.api.token.transfer(
    { assetId, transfers: [{ recipient, amount }] },
    {
      headers: { "Idempotency-Key": crypto.randomUUID() },
    }
  );
}

Pass the real request headers to a server action that writes. A hand-built header set with no Origin is refused.

The action resolves once the platform's wait ends, with the settled resource, or with a 202 handle if settlement outlasts the wait. Call revalidatePath or revalidateTag after a settled result and the revalidated render reads data that reflects the write. A handle means the action should report the write as pending instead. Set a settlement bound the action's own timeout can accommodate, since a serverless function that is killed mid-wait leaves the instruction live with nobody watching it.

Hydrating the browser cache

Fetching on the server and continuing in the browser without a second fetch on mount takes two steps. Dehydrate a query client in the server component, then hydrate it around the tree:

// app/tokens/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";

export default async function TokensPage() {
  const dalp = createDalpClient({
    baseUrl: process.env.DALP_API_URL!,
    runtime: "server",
    ssr: { requestHeaders: await headers() },
  });

  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(dalp.api.token.list.queryOptions({ page: { limit: 50 } }));

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TokenTableClient />
    </HydrationBoundary>
  );
}
// app/tokens/token-table-client.tsx
"use client";

export function TokenTableClient() {
  const { data } = useSuspenseQuery(browserDalp.api.token.list.queryOptions({ page: { limit: 50 } }));
  return <Table rows={data.data} />;
}

The two clients differ, but the keys match. queryOptions derives its key from route identity and canonicalised input, with nothing about the base URL or the credential in it, so the server-side entry and the browser-side query name the same thing. A refetch on mount can also follow TanStack Query's staleness policy. Check staleTime and refetchOnMount, as well as matching parameters and query keys.

Create the server-side QueryClient per request. A shared one caches one user's data across requests, which is the same class of mistake as a shared client.

Services and jobs outside a framework

Outside a rendering framework no session exists to forward. Use an API key and build the client once:

import { createDalpClient, secret } from "@settlemint/dalp-sdk";

export const dalp = createDalpClient({
  baseUrl: process.env.DALP_API_URL!,
  apiKey: secret(process.env.DALP_API_KEY!),
  runtime: "server",
  settlementTimeoutMs: 90_000,
});

Choose a settlement wait that fits the job timeout. The platform holds a response for 5 to 99 seconds, and the SDK clamps a larger value to 99. A longer wait can reduce pending handles. A shorter wait returns control sooner. The job acts in the organisation its API key was issued for, so issue one key per organisation the job serves.

Next

The Effect surface: Effect.

Was this page helpful?