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 module | What replaces it here |
|---|
lib/session.server.ts | session-plugin, reached through sessionContext |
lib/csrf.server.ts | the same plugin’s form-CSRF middleware, token via csrfContext |
lib/sse.server.ts | sse-plugin |
lib/kv.server.ts | cache-plugin / storage-plugin |
lib/service-logger.server.ts | logger-plugin, reached through loggerContext |
module-level caches in config/services.server.ts | the 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.
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');
});
});