---
title: "React Router"
description: "The framework-mode install. Server rendering, route modules, and where the stylesheet goes."
url: "https://opsinjs.pensievelabs.org/start/installation/react-router"
source: "https://opsinjs.pensievelabs.org/start/installation/react-router.md"
section: "Start here"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["react router install", "remix opsinjs", "framework mode"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="guide" />

## Overview [#overview]

React Router in framework mode gives you server rendering with a route-module data
story, which suits health interfaces well: the value, its range and its status are
all loader data, and the component that renders them is display-only.

<Callout title="Shipped, and no published host yet">
  The sixty implemented components are real code and their registry items
  carry the source `shadcn add` copies. What is missing is the host:
  `opsinjs.pensievelabs.org` is not serving, so the commands below resolve only against a
  registry you point `@opsinjs` at yourself, and there is no npm package to fall
  back to. [Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
</Callout>

If you are on Remix, upgrade to React Router 7 framework mode first. There is no
separate Remix path and there will not be one.

## Set it up [#set-it-up]

<Steps>
  ### Start from a framework-mode app with Tailwind v4 [#start-from-a-framework-mode-app-with-tailwind-v4]

  React Router framework mode builds on Vite, so Tailwind is installed the Vite way.
  Everything in [Vite](./vite.mdx) about source scanning applies here too.

  ### Confirm `@/` resolves in both places [#confirm--resolves-in-both-places]

  `vite.config.ts` and `tsconfig.json` must agree, exactly as in a plain Vite app.

  ### Add the registry entry and a component [#add-the-registry-entry-and-a-component]

  <CodeBlockTabs defaultValue="npm" groupId="package-manager">
    <CodeBlockTabsList>
      <CodeBlockTabsTrigger value="npm">
        npm
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="pnpm">
        pnpm
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="yarn">
        yarn
      </CodeBlockTabsTrigger>

      <CodeBlockTabsTrigger value="bun">
        bun
      </CodeBlockTabsTrigger>
    </CodeBlockTabsList>

    <CodeBlockTab value="npm">
      ```bash
      npx shadcn@latest add @opsinjs/result-card
      ```
    </CodeBlockTab>

    <CodeBlockTab value="pnpm">
      ```bash
      pnpm dlx shadcn@latest add @opsinjs/result-card
      ```
    </CodeBlockTab>

    <CodeBlockTab value="yarn">
      ```bash
      yarn dlx shadcn@latest add @opsinjs/result-card
      ```
    </CodeBlockTab>

    <CodeBlockTab value="bun">
      ```bash
      bun x shadcn@latest add @opsinjs/result-card
      ```
    </CodeBlockTab>
  </CodeBlockTabs>
</Steps>

## Place the stylesheet [#place-the-stylesheet]

Framework mode exposes stylesheets through the root route's `links` export, which
means the ordering rule is expressed as import order in one file rather than as a
sequence of `@import` statements. The rule is unchanged: Tailwind, then the opsinjs
token layer, then your overrides.

```css title="app/app.css"
@import "tailwindcss";
@import "./opsinjs.css";

@source "./components/opsinjs/**/*.{ts,tsx}";
```

Export it from the root route so it is present on every document, not only on the
routes that happen to use a component.

## Keep display components on the server [#keep-display-components-on-the-server]

The natural shape here is the one opsinjs is designed for:

* The **loader** fetches the value, resolves the reference range and applies your
  clinical mapping to produce a status. All three are server-side, in one place,
  where they can be tested and reviewed.
* The **component** receives `value`, `range` and `status` as props and renders
  them. No computation, no thresholds, no client JavaScript.

That split is not a React Router idiom being imposed on opsinjs; it is what the
system's central rule looks like when a framework happens to make it easy. See
[Safety, scope and limitations](../safety-scope-and-limitations.mdx).

## Verify it worked [#verify-it-worked]

1. **The stylesheet is on every document**, including error and 404 routes. A
   missing stylesheet on the error route is a classic and only shows up on a bad
   day.
2. **A copied component renders styled.** If not, check the `@source` line.
3. **Colours resolve through custom properties.**
4. **The status in your rendered markup came from the loader**, not from anything
   inside the component.

## Troubleshooting [#troubleshooting]

**Styles missing on error boundaries.** The links export on the root route did not
apply, or the error boundary renders outside it. This affects every stylesheet in
your app, not just opsinjs.

**Hydration mismatch on a formatted time.** Server and browser time zones differ.
Use the components' documented hydration-safe formatting rather than formatting in
a render function.

**Unstyled components.** As always, `@source`. See [Vite](./vite.mdx).

**Coming from Remix v2.** Upgrade first; the alias and stylesheet mechanics changed
and following this page on Remix v2 will mislead you.

## Next [#next]

* [Next.js](./next.mdx) has the CSS order rule stated in full.
* [Quick start](../quick-start.mdx) covers the loader-plus-display-component
  shape, end to end.
* [components.json](./components-json.mdx) covers the aliases that must agree.
