A Remix 3 application with end-to-end privacy plumbing: a PolicyStack config drives both an on-screen cookie consent banner and the generated privacy & cookie policy pages.
| Path | Owner | Description |
|---|---|---|
/ |
app/controllers/home.tsx |
Home page. |
/auth |
app/controllers/auth.tsx |
Placeholder for sign-in / sign-up flows. |
/privacy |
app/controllers/privacy.tsx |
Server-rendered privacy policy compiled from app/data/openpolicy.ts. |
/cookies |
app/controllers/cookies.tsx |
Server-rendered cookie policy compiled from the same config. |
/assets/* |
app/router.ts → app/assets.ts |
Bundles and serves browser modules through the Remix asset server. |
- Remix 3 (
remix@3.0.0-beta.0) — server-first, Web-APIs-everywhere. Components are not React; the runtime usesclientEntry+runfor hydration. - OpenCookies (
@opencookies/core) — framework-agnostic consent store. A smallclientEntrywrapper inapp/ui/cookie-banner.tsxbridges the store'ssubscribe()intohandle.update(). Categories are derived from the OpenPolicy config so the banner and the policy can't drift apart. - OpenPolicy (
@openpolicy/sdk,@openpolicy/core) — policy-as-code.app/ui/policy-document.tsxwalks the framework-agnostic Document AST (Document → DocumentSection → ContentNode → InlineNode) and emits Remix UI JSX, so both policy pages are server-rendered with no client JS.
app/
├── assets.ts # Asset server config (allow-list, fileMap)
├── assets/entry.ts # Browser entry; calls run() to hydrate clientEntry components
├── data/openpolicy.ts # Single source of truth: company info, data, cookies, jurisdictions
├── controllers/ # Flat controllers, one file per route
│ ├── home.tsx
│ ├── auth.tsx
│ ├── privacy.tsx
│ └── cookies.tsx
├── routes.ts # Typed URL contract
├── router.ts # Wires routes to handlers
├── ui/ # Shared UI
│ ├── document.tsx # HTML document shell
│ ├── layout.tsx # Header / nav / main / cookie banner
│ ├── cookie-banner.tsx # clientEntry banner backed by OpenCookies
│ └── policy-document.tsx # AST walker for OpenPolicy Documents
└── utils/render.tsx # renderToStream + resolveClientEntry + resolveFrame
app/ui/cookie-banner.tsxexportsCookieBanner = clientEntry(import.meta.url, fn).- On the server,
app/utils/render.tsxmaps thefile://entry ID to a public asset URL viaassets.getHref(), and emits a<script type="application/json" id="rmx-data">tag listing the entries to hydrate. app/assets/entry.tscallsrun({ loadModule, resolveFrame }); the runtime dynamic-imports the banner module and runs setup again on the client.- The setup function guards localStorage access with
typeof window !== 'undefined', creates an OpenCookies store, subscribes for updates, and registers cleanup againsthandle.signal.
app/data/openpolicy.tsexports apolicyvalue built withdefineConfigfrom@openpolicy/sdk.- Each controller calls
expandOpenPolicyConfig(policy), picks the rightPolicyInput(privacy or cookie), and compiles it once at module load withcompile(input)from@openpolicy/core. - The resulting
Documentis passed to<PolicyDocument doc={doc} />, which walks the AST and emits standard HTML (<section>/<h2>/<p>/<ul>/<table>/<a>). - Pages are fully server-rendered. No
clientEntry, no hydration, no client JS for the policy content.
pnpm i
pnpm run start
pnpm test
pnpm run typecheckThe server listens on http://localhost:44100 (override with PORT).
- Keep route handlers flat in
app/controllers/. Promote to a folder withcontroller.tsxonly when a route grows nested routes or multiple actions. - Add
app/middleware/,public/, ortest/when something actually needs them. - Shared UI belongs in
app/ui/. Don't introduceapp/lib/orapp/components/— they overlap withapp/ui/and become dumping grounds. - When you add a new client-loaded UI module, allow-list it in
app/assets.ts(or widenapp/ui/**). New data modules go underapp/data/**, already allow-listed.