RatchetRatchet

Web App

A server-rendered React Router data-mode site, bundled with Bun.

@egig/ratchet/web (the src/web/ package) lets you build the public-facing site as code: you write routes/**/*.tsx in a folder convention, and Ratchet scans them at codegen time, server-renders them with streaming SSR, and bundles the client with Bun.build.

The site is opt-in: it's mounted only when routes/root.tsx exists. Without it, / returns 404.

Layout

routes/
  root.tsx          # renders the whole <html> document — required
  index.tsx         # /
  about.tsx         # /about
  blog/
    _layout.tsx     # layout component wrapping /blog/* (renders <Outlet/>)
    index.tsx       # /blog
    $slug.tsx       # /blog/:slug
  _marketing/       # leading _ = pathless layout group (no URL segment)
    _layout.tsx
    pricing.tsx     # /pricing
  sitemap[.]xml.tsx # /sitemap.xml   ([x] escapes a literal character)
  $.tsx             # splat — matches anything unmatched
public/             # static files served at /  (favicon.ico, robots.txt, /img/…)
  • Dynamic segment: a file or folder named $name:name. Bare $ → splat.
  • Folder = nesting. A folder with no _layout.tsx (and no <folder>.tsx sibling) is a path-only prefix route.
  • Resource route: a module that exports a loader/action but no default returns its loader's raw Response — no React render. Use it for sitemaps, feeds, webhooks, OG images.

Configure the directories in ratchet.config.ts (defaults shown):

export default defineConfig({
  // …
  routesDir: 'routes',
  publicDir: 'public',
});

Route modules

Each routes/**/*.tsx may export:

ExportRunsPurpose
defaultclient + serverthe route component
loaderserver onlydata for the component (see context below)
actionserver onlyhandle a non-GET submission
metaclient + server[{ title }, { name, content }, …] — merged leaf-first
ErrorBoundaryclient + serverrendered when this route or a child throws
headersserver onlyresponse headers for the document / .data response
handle, shouldRevalidateclient + serverstandard React Router route config

loader/action/headers and their now-dead imports are stripped from the browser bundle at build time. Anything with real side effects that must never reach the browser (a database client, node:* code) belongs in a *.server.ts file — those are emptied for the client build.

root.tsx

Renders the <html>. Import <Meta /> and <Scripts /> from @egig/ratchet/web and place them in <head> / end of <body>:

import { Outlet } from 'react-router';
import { Meta, Scripts, getWebContext } from '@egig/ratchet/web';
import type { LoaderFunctionArgs } from 'react-router';

export async function loader({ context }: LoaderFunctionArgs) {
  const settings = await getWebContext(context).settings.get('website');
  return { settings };
}

export const meta = ({ data }) => [{ title: data?.settings?.title ?? 'My site' }];

export default function Root({ loaderData }) {
  return (
    <html lang="en">
      <head><meta charSet="utf-8" /><Meta /></head>
      <body><Outlet /><Scripts /></body>
    </html>
  );
}

If root.tsx doesn't export an ErrorBoundary, a framework default renders one (with the right HTTP status).

Loaders always run on the server

Ratchet uses single fetch: loader/action never run in the browser. On the first request they run during SSR; on a later client navigation the browser fetches one turbo-stream <path>.data response and the server runs the loaders for it. So a loader can always reach the database directly through its context:

import { getWebContext } from '@egig/ratchet/web';
import { sql } from 'drizzle-orm';

export async function loader({ params, context }) {
  const { db, session, settings } = getWebContext(context);
  const rows = await db.execute(sql`select * from products where sku = ${params.sku}`);
  if (!rows[0]) throw new Response('Not found', { status: 404 });
  return { product: rows[0] };
}

context (WebLoaderContext) holds:

  • db — the Drizzle client.
  • session — the resolved session ({ user, permissions, can(resource, action) }) from the same ratchet_session cookie the console and REST API use, or null.
  • settings.get(domain) — a per-request memoized Domain Settings reader.
  • registry — the name → model map.
  • storage — the FileStorage adapter.
  • requirePermission(resource, action) — throws a 401/403 Response (React Router routes it to the nearest ErrorBoundary).

Throwing a Response from a loader works as expected — redirect('/login'), new Response(null, { status: 403 }), data(x, { status: 404 }).

Content-managed pages (scaffolded)

There is no built-in website domain. ratchet init scaffolds models/website/ in your project — editable source you own:

  • page.model.ts — a Page model (slug, title, metaDescription, a body text field, status, navLocation/navOrder), edited in the console. body is sanitized with sanitize-html on every write (allowlisted tags/attributes, no scripts, no javascript: URLs), so rendering it with dangerouslySetInnerHTML is safe.
  • contact.model.ts — a Contact model for the contact form.
  • settings.domain.ts — the website Domain Settings (title, description, siteUrl, noindex, favicon, ogImage), read by the routes through settings.get('website').

The scaffolded routes/$.tsx is a splat route that looks a published page up by slug:

export async function loader({ params, context }) {
  const { db } = getWebContext(context);
  const rows = await db.execute(
    sql`select title, body, meta_description from pages
        where slug = ${params['*']} and status = 'published' and deleted_at is null limit 1`,
  );
  const page = rows[0];
  if (!page) throw data('Not found', { status: 404 });
  return { page };
}
// … <article dangerouslySetInnerHTML={{ __html: page.body }} />

ratchet init also scaffolds routes/root.tsx (header/footer nav built from the pages that set navLocation) and a routes/contact.tsx whose action inserts a row into the contacts model (no public write endpoint — the insert goes straight through the loader context.db):

export async function action({ request, context }) {
  const form = await request.formData();
  if (form.get('company')) return { ok: true }; // honeypot
  const { db } = getWebContext(context);
  await db.execute(
    sql`insert into contacts (id, created_at, updated_at, name, email, message, status)
        values (${crypto.randomUUID()}, now(), now(), ${name}, ${email}, ${message}, 'new')`,
  );
  return { ok: true };
}

There is no first-run seeding — create your pages in the console (Pages).

Build & serve

  • ratchet generate scans routesDir and writes .ratchet/app-routes.{server,client}.ts.
  • ratchet dev regenerates + rebuilds the client bundle + restarts on any change under routesDir (full reload — no HMR yet).
  • ratchet build emits a hashed client bundle + manifest.
  • ratchet serve mounts the site at / (after /api/*, the console, and /_site-assets).

react / react-dom are peer dependencies (the console already needs them); react-router is what your route files import directly. ratchet init scaffolds routes/root.tsx, routes/index.tsx (a hand-authored landing page), routes/$.tsx, routes/contact.tsx, public/theme.css (a production-ready light/dark theme), and the models/website/ content set above.

Current limitations

Single client bundle (no route-level code splitting), no +types typegen, every client navigation re-runs all matched loaders server-side (no ?_routes= filter), no <Link> prefetch, no clientLoader/clientAction/links, no HMR. All are additive to lift later — see ADR 0003.

On this page