Full-stack example — React Router 8 SSR served by the kernel

Try it
This example runs inside the Setu-TS repository. To start a similar project:
npm create @setu-ts my-app -- --template full-stack
cd my-app && npm install
npm run start:dev
The commands further down run inside a clone of the repository.

A React Router 8 framework-mode application served by a Setu-TS application, composed through createFullStackAppFromConfig from @setu-ts/full-stack-starter. It is the runnable counterpart to setu new --template full-stack.

pnpm --dir apps/full-stack run start   # builds the frontend, then serves on http://localhost:3000
pnpm --dir apps/full-stack run smoke   # what CI runs
pnpm --dir apps/full-stack run test    # the removal claim, asserted

What the smoke check proves

An SSR-rendered route returns HTML containing a row that was written through the database capability — not a string in a component. That is the whole point: it is evidence that populateLoadContext bridged the kernel’s service registry into a React Router loader.

It then signs in through the <Form> on /login, echoing the CSRF token the session minted. A 302 rather than a 403 proves the synchronizer token round-tripped through the session plugin’s middleware, which is the mechanism a progressive-enhancement form needs and a stateless Origin/Referer check structurally cannot provide.

What is NOT here, and why that is the point

A conventional React Router application grows these, and this one has none of them:

Conventional moduleWhat replaces it here
lib/session.server.tssession-plugin, reached through sessionContext
lib/csrf.server.tsthe same plugin’s form-CSRF middleware, token via csrfContext
lib/sse.server.tssse-plugin
lib/kv.server.tscache-plugin / storage-plugin
lib/service-logger.server.tslogger-plugin, reached through loggerContext
module-level caches in config/services.server.tsthe kernel’s service registry

app/config/services.server.ts still exists — typed accessors are genuinely app code — but it holds no state. test/removal.test.ts asserts both halves of that claim, because a claim nothing executes is a comment.

Layering

routes → features → services → models, with lib/ for glue and .server.ts marking server-only modules. A route parses the request and renders; a feature composes a use case; a service talks to the outside world; a model is plain data shared with the browser.

Toolchain

The repository uses Node 24 and pnpm. The build script runs React Router’s Vite build; start and smoke both build first. Install dependencies at the repository root with pnpm install --frozen-lockfile. Frontend tooling stays in the app’s dependencies, outside the published framework dependency graph (AI_GUIDELINES §12.2).

build/, .react-router/ and node_modules/ are generated and ignored by repository formatting and linting. The app’s check script uses its generated tsconfig.json, which includes the app/ TypeScript and TSX files. pnpm run check:apps runs that check, then the smoke and test scripts. Vite’s build strips types; the TypeScript check is separate.

Browser verification

The dedicated pnpm run check:browser gate at the repository root builds and tests this example and a fresh CLI full-stack scaffold in real Chromium. It checks SSR, hydration, asset delivery, link/Form transitions, HttpOnly cookies and native no-JavaScript login. Aborting the client entry proves transition checks fail while SSR still renders; removing a referenced asset proves the asset check names the missing bundle.

Install the pinned browser with pnpm --dir apps/full-stack exec playwright install chromium. A missing browser exits 77 locally and fails in CI, which installs Chromium in its dedicated job. The harness resolves Playwright’s browser cache, including PLAYWRIGHT_BROWSERS_PATH. Ordinary tests do not launch a browser. Set PORT to choose the listening port (default 3000).

The full-stack configuration selects Referrer-Policy: same-origin so native form posts retain their origin for React Router’s action check. Cross-origin referrers remain suppressed; both session CSRF and React Router origin verification remain active.

Cloudflare Workers

On Workers the assetsDir option is omitted: there is no filesystem, so the framework registers no asset route at all and the platform’s static-asset binding serves them. Scaffolding the same template with --runtime cloudflare-workers emits exactly that difference, and the CLI’s own end-to-end test pins it. This example runs on Node through check:apps.

Session secret

setu.config.ts falls back to a development secret so the example runs with no environment at all. A real deployment uses config.getOrThrow<string>('SESSION_SECRET') and refuses to boot without one — which is what the CLI scaffolds.

Read the example deep dive for the concept and source walkthrough.

Read, in order

setu.config.ts

import { CAPABILITIES, type ILogger } from '@setu-ts/common';
import type { IDatabaseService } from '@setu-ts/database-plugin';
import { getCsrfToken, getSession } from '@setu-ts/session-plugin';
import { createFullStackAppFromConfig } from '@setu-ts/full-stack-starter';
import type { IKernelApplication } from '@setu-ts/kernel';

import {
  csrfContext,
  databaseContext,
  loggerContext,
  sessionContext,
} from '~/lib/context-keys.server.ts';

/**
 * Development session secret.
 *
 * A real deployment uses `config.getOrThrow<string>('SESSION_SECRET')` and
 * fails to boot without one — which is what `setu new --template full-stack`
 * emits. This example falls back so that `pnpm --dir apps/full-stack run smoke` runs with no
 * environment at all; the value is public, in a public repository, and is
 * therefore worth exactly nothing.
 */
const DEV_SESSION_SECRET = 'example-only-session-secret-do-not-deploy';

/**
 * Builds the application.
 *
 * `setu` imports this factory to discover plugin-contributed CLI commands, so
 * it must NOT start the server — `main.ts` and `smoke.ts` own that.
 *
 * @returns The configured, unstarted application
 */
export function createApp(): Promise<IKernelApplication> {
  return createFullStackAppFromConfig((config) => ({
    // The SSR plugin, the session, and the database are all GATED arms of the
    // full-stack starter: a default-options full-stack app registers none of
    // them. Passing all three is what makes this example prove anything.
    reactRouter: {
      // Absolute, deliberately: the plugin does `await import(serverBuildPath)`,
      // and a relative specifier there would resolve against the PLUGIN's
      // module rather than this project.
      serverBuildPath: new URL('./build/server/index.js', import.meta.url).href,
      assetsDir: './build/client/assets',
      populateLoadContext: (ctx, context) => {
        // The bridge between the kernel request context and a React Router
        // loader: getSession() needs the kernel context, which a loader never
        // sees. The keys come from contextKeyFor(), so the copy of
        // context-keys.server.ts that Vite inlined into the server build
        // resolves to the same key objects this module holds.
        context.set(sessionContext, getSession(ctx));
        context.set(csrfContext, getCsrfToken(ctx));
        context.set(loggerContext, ctx.services.get<ILogger>(CAPABILITIES.LOGGER));
        context.set(databaseContext, ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE));
      },
    },
    session: {
      secret: config.get<string>('SESSION_SECRET', { default: DEV_SESSION_SECRET }),
      csrf: {},
    },
    // Native form posts need a same-origin Origin header for React Router's
    // action check. Keep referrers private when navigating to another origin.
    httpSecurity: { headers: { referrerPolicy: 'same-origin' } },
    database: { type: 'memory' },
  }));
}

app/routes/_app/_index.tsx

import { Link, useLoaderData } from 'react-router';

import type { AppLoadContext } from '~/lib/load-context.ts';
import { getSession } from '~/config/services.server.ts';

/**
 * The landing page.
 *
 * This route exists because without it `/` matches a `layout()` with no child:
 * `<Outlet />` renders nothing, and the server answers 200 with an empty
 * document — a blank page, which is what the first person to open this example
 * actually saw. `smoke.ts` now requests `/` for that reason.
 *
 * It also reads the session, so the landing page demonstrates a second
 * capability rather than being decoration: sign in on /login and the greeting
 * changes, because the session cookie survives the redirect.
 */
export function loader({ context }: { context: AppLoadContext }) {
  const session = getSession(context);
  return { signedInAs: session.get<string>('userEmail') ?? null };
}

export default function IndexRoute() {
  const { signedInAs } = useLoaderData<typeof loader>();

  return (
    <section>
      <h1>Setu-TS — full-stack example</h1>
      <p>
        A React Router 8 application server-rendered by the kernel through{' '}
        <code>react-router-plugin</code>, composed with <code>createFullStackAppFromConfig</code>.
      </p>
      <p>
        {signedInAs === null ? (
          <>You are not signed in.</>
        ) : (
          <>
            Signed in as <strong>{signedInAs}</strong> — the session is server-authoritative and the
            cookie is HttpOnly.
          </>
        )}
      </p>
      <ul>
        <li>
          <Link to="/products">Products</Link> — rows written through the database capability and
          read back by a loader, so the page is evidence the load-context bridge works.
        </li>
        <li>
          <Link to="/login">Sign in</Link> — a progressive-enhancement form guarded by the session's
          synchronizer CSRF token.
        </li>
      </ul>
    </section>
  );
}

app/routes/_app/forbidden.tsx

import { data, type MiddlewareFunction, useRouteError } from 'react-router';

/** Refusal used by the real-build smoke to pin the React Router boundary. */
export const middleware: MiddlewareFunction<Response>[] = [
  () => {
    throw data(null, { status: 403 });
  },
];

export default function ForbiddenRoute() {
  return null;
}

/** React Router, not the kernel error responder, renders document refusals. */
export function ErrorBoundary() {
  const error = useRouteError() as { status?: number };
  return <h1>Route boundary refusal: {error.status ?? 'unknown'}</h1>;
}

app/routes/_app/products._index.tsx

import { useLoaderData } from 'react-router';

import type { AppLoadContext } from '~/lib/load-context.ts';
import { buildProductsView } from '~/features/products/products.server.ts';
import { formatPrice } from '~/models/product.ts';

/**
 * Loads the view on the server.
 *
 * The request context carries the framework services `setu.config.ts` put
 * there, so the logger and database are reachable without a module-level
 * singleton or a second DI container — and without importing a framework
 * package into a module that also ships to the browser.
 *
 * Everything rendered below came out of the database capability. That is the
 * point of this example, and `smoke.ts` asserts it by seeding a row and then
 * looking for it in this page's HTML.
 */
export async function loader({ context }: { context: AppLoadContext }) {
  return await buildProductsView(context);
}

export default function ProductsRoute() {
  const { products, total } = useLoaderData<typeof loader>();

  return (
    <section>
      <h1>Products ({total})</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} — {formatPrice(product)}
          </li>
        ))}
      </ul>
    </section>
  );
}

app/routes/_auth/login.tsx

import { Form, redirect, useActionData, useLoaderData } from 'react-router';

import type { AppLoadContext } from '~/lib/load-context.ts';
import { getCsrfToken, getSession } from '~/config/services.server.ts';

/**
 * Hands the form its CSRF token.
 *
 * The token comes from the session, placed on the request context by
 * `setu.config.ts`. A progressive-enhancement `<Form>` cannot set a custom
 * header, which is why the synchronizer-token strategy — not the stateless
 * Origin check — is what guards this action.
 */
export function loader({ context }: { context: AppLoadContext }) {
  // Not `?? ''`: an absent token means the wiring is broken, and a form posting
  // an empty token would fail the check at submit time instead of here.
  return { csrfToken: getCsrfToken(context) };
}

/**
 * Handles the sign-in post.
 *
 * The CSRF token is NOT checked here: the session plugin's form-CSRF
 * middleware runs before this route is ever reached and answers 403 on a
 * missing or mismatched token. So this action only runs for a request that
 * already passed the check — which is what `smoke.ts` step 3 proves, by
 * observing a 302 rather than a 403.
 *
 * The session write is what makes the login stick — the plugin commits the
 * session onto the response after the handler returns, so the redirect below
 * carries the updated cookie.
 */
export async function action({ request, context }: { request: Request; context: AppLoadContext }) {
  const form = await request.formData();
  const email = String(form.get('email') ?? '');

  // Replace with a real credential check — resolve the auth service through a
  // context key, exactly as the database is resolved.
  if (email === '') {
    return { error: 'Email is required.' };
  }

  const session = getSession(context);
  session.set('userEmail', email);

  return redirect('/products');
}

export default function LoginRoute() {
  const { csrfToken } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      {actionData?.error ? <p role="alert">{actionData.error}</p> : null}
      <input type="hidden" name="_csrf" value={csrfToken} />
      <label>
        Email <input type="email" name="email" required />
      </label>
      <label>
        Password <input type="password" name="password" required />
      </label>
      <button type="submit">Sign in</button>
    </Form>
  );
}

smoke.ts

/**
 * Smoke check for the full-stack example.
 *
 * The rendered pages are evidence, not decoration: the products page must
 * show rows written through the database capability, and the login form must
 * round-trip the session's CSRF token. Driven through `app.fetch`, because the
 * SSR body is a stream and the login step reads `Set-Cookie`.
 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { CAPABILITIES } from '@setu-ts/common';
import type { IDatabaseService } from '@setu-ts/database-plugin';
import type { IKernelApplication } from '@setu-ts/kernel';
import { createApp } from './setu.config.ts';
import { formatPrice } from '~/models/product.ts';
import type { Product } from '~/models/product.ts';
import { seedProducts } from '~/services/products.server.ts';

const ORIGIN = 'http://full-stack.test';

/** Pulls the hidden CSRF field out of the rendered login form. */
function readCsrfToken(html: string): string {
  const token = /name="_csrf"\s+value="([^"]+)"/.exec(html)?.[1];
  assert.ok(token, 'the login form rendered no CSRF token');
  return token;
}

/** Collects the cookies a response set, as a single request header value. */
function readCookies(response: Response): string {
  const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(';', 1)[0]);
  assert.ok(cookies.length > 0, 'no session cookie was set');
  return cookies.join('; ');
}

describe('full-stack example', () => {
  let app: IKernelApplication;
  let seeded: readonly Product[];

  const get = (path: string): Promise<Response> => app.fetch(new Request(`${ORIGIN}${path}`));

  before(async () => {
    app = await createApp();
    await app.start();
    seeded = await seedProducts(app.services.get<IDatabaseService>(CAPABILITIES.DATABASE));
    assert.ok(seeded.length > 0, 'seeding produced no products');
  });

  after(async () => {
    await app.stop();
  });

  it('server-renders the products written through the database capability', async () => {
    const response = await get('/products');
    assert.equal(response.status, 200);
    const html = await response.text();

    assert.match(html, /<h1>Products/);
    // Asserted by name and price rather than by count: React splits rendered
    // numbers with comment markers, so `Products (2)` is not a literal substring.
    for (const product of seeded) {
      assert.ok(
        html.includes(product.name) && html.includes(formatPrice(product)),
        `"${product.name}" is missing: the load context did not reach the loader`,
      );
    }
  });

  it('renders a landing page at /, not an empty layout', async () => {
    const response = await get('/');
    assert.equal(response.status, 200);
    const html = await response.text();

    // A missing index route answers 200 with an empty <body>, so assert content.
    assert.match(html, /<h1>/);
    assert.match(html, /href="\/products"/);
  });

  it('serves the OpenAPI document beside the SSR catch-all', async () => {
    const response = await get('/openapi.json');

    assert.equal(response.status, 200, 'the SSR catch-all is shadowing /openapi.json');
    assert.match(response.headers.get('content-type') ?? '', /application\/json/);
    assert.equal(typeof (await response.json()).openapi, 'string');
  });

  it('serves Swagger UI beside the SSR catch-all', async () => {
    const response = await get('/docs');

    assert.equal(response.status, 200);
    assert.match(await response.text(), /swagger/);
  });

  it("renders a refused document through React Router's error boundary", async () => {
    const response = await get('/forbidden');

    assert.equal(response.status, 403);
    assert.match(response.headers.get('content-type') ?? '', /text\/html/);
    assert.match(await response.text(), /Route boundary refusal:/);
  });

  it('accepts a login form that echoes the session CSRF token', async () => {
    const loginPage = await get('/login');
    assert.equal(loginPage.status, 200);
    const csrfToken = readCsrfToken(await loginPage.text());

    const submitted = await app.fetch(
      new Request(`${ORIGIN}/login`, {
        method: 'POST',
        headers: {
          'content-type': 'application/x-www-form-urlencoded',
          cookie: readCookies(loginPage),
        },
        body: new URLSearchParams({
          _csrf: csrfToken,
          email: 'reader@example.test',
          password: 'x',
        }),
      }),
    );

    // 403 would mean the synchronizer token did not round-trip through the session.
    assert.equal(submitted.status, 302);
    assert.equal(submitted.headers.get('location'), '/products');
  });
});