SettleMint
API referenceTypeScript SDK

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 allowlist.

Overview

Rendering on a server puts one problem in front of everything else: the credential lives in the browser and the render does not. A server component or a loader that calls the Platform API has no cookie of its own, so it either forwards the user's or it acts as something else entirely.

Both are legitimate and they answer different questions. 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 not a performance mistake; it is an authorization mistake, and on a multi-tenant platform it is the one that leaks data across tenants.

This page covers the choice, the mechanism the SDK provides for the first case, and the wiring for TanStack Start and Next.js.

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 organization'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.

The test is one question: if two different users load this page, should they see different bytes? If yes, forward the session. Rendering per-user content with a service key is how a page shows one tenant's data to another, and it will 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 carries the caller's cookie to your instance.

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

Forwarding a user's cookie from a server is a capability worth constraining, and the SDK constrains it rather than trusting the call site.

Destinations are allowlisted. Requests may only reach your instance's origin, plus anything you name in ssr.allowedOrigins. An unapproved destination throws before the request goes out, so a mistyped base URL or a redirect into somewhere else cannot carry a session there.

Redirects lose credentials at the boundary. Redirects are followed manually, up to five. As long as each hop stays inside the allowlist, the cookie and context headers travel with it. The first hop outside strips them: cookie, authorization, API key, the acting-context headers, tracing headers, and the idempotency key. A request whose body would have to cross that boundary is refused rather than sent stripped, because a write that arrives without its acting context is worse than a write that did not arrive.

Cookie-authenticated writes need a trusted origin. A mutation carrying a forwarded cookie is refused unless the incoming request declared an Origin inside the allowlist and did not declare itself cross-site. That is cross-site request forgery protection at the SDK boundary, 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 { getWebRequest } from "@tanstack/react-start/server";

export function dalpServer() {
  const request = getWebRequest();
  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 key point is that the loader and the component agree on the cache key while disagreeing on the fetch. 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 boundary 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";

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() },
  });

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

A server action that writes is exactly the case the origin check is protecting, so pass the real headers rather than assembling a set by hand. A hand-built header bag with no Origin is refused, and that refusal is the check doing its job.

The action resolves once the platform's wait ends, with the settled resource if the chain kept up or a 202 handle if it did not. 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; the keys do not. queryOptions derives its key from route identity and canonicalized 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.

Node services and jobs

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",
  context: { organizationId: process.env.DALP_ORGANIZATION_ID! },
  settlementTimeoutMs: 120_000,
});

Choose a settlement wait that fits the job timeout. A longer wait can reduce pending handles; a shorter wait returns control sooner. Set context.organizationId to the organization the job acts for.

Checklist

  • One client per request when forwarding a session; one shared client when using a key.
  • runtime: "server" set explicitly wherever the inference is not obviously right.
  • API keys read from the environment at runtime, never behind a public bundler prefix.
  • Server-only modules kept out of the client bundle.
  • A fresh QueryClient per request.
  • Query keys derived from queryOptions on both sides rather than written twice.

Next

The Effect surface: Effect.

On this page