---
title: "Next.js"
description: "The reference install. App Router, Tailwind v4, the exact CSS import position, and where the theme class belongs."
url: "https://opsinjs.pensievelabs.org/start/installation/next"
source: "https://opsinjs.pensievelabs.org/start/installation/next.md"
section: "Start here"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["next.js install", "app router setup", "nextjs opsinjs"]
---

> 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]

Next.js with the App Router is the reference target: it is what opsinjs is
developed against, and the combination most likely to be exercised before a
release. Read this page even if you use another framework. The CSS order rule and
the theme-class rule are identical everywhere, and this is where they are stated in
full.

<Callout title="Shipped, and no published host yet">
  `status-pill` and the fifty-nine other implemented components are real code,
  and their registry items carry the source `shadcn add` copies. What does not
  exist yet is the canonical 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. See
  [Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
</Callout>

This page assumes a Next.js app on React 19 with Tailwind v4 already working. If
Tailwind is not set up, do that first, because opsinjs cannot compensate for a
Tailwind installation that is not scanning your source.

## Set up the project [#set-up-the-project]

<Steps>
  ### Create or open a Next.js app [#create-or-open-a-nextjs-app]

  Nothing about the app has to be special. The App Router is assumed below; the
  Pages Router works too, with one difference noted at the end.

  ### Add the registry namespace [#add-the-registry-namespace]

  opsinjs is consumed through the shadcn CLI. If you have never run it in this
  project, initialise it first, then add the `@opsinjs` entry to `components.json`.
  Every field is annotated in [components.json](./components-json.mdx).

  ### Add a component [#add-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/status-pill
      ```
    </CodeBlockTab>

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

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

    <CodeBlockTab value="bun">
      ```bash
      bun x shadcn@latest add @opsinjs/status-pill
      ```
    </CodeBlockTab>
  </CodeBlockTabs>

  This copies source into your repository: the component, plus the two `lib`
  modules every opsinjs component imports. It writes no CSS at all. The token
  layer is a separate registry item, and until you add it every custom property
  these components read resolves to nothing. That failure is what the next
  section is about. [Presets](../../theming/presets.mdx) has the item and what it
  writes.
</Steps>

## Get the CSS order right [#get-the-css-order-right]

This is the part that is worth reading slowly. opsinjs's stylesheet position is
load-bearing, and the failure mode is quiet: components lay out perfectly and are
the wrong colour.

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

/* The opsinjs token layer, in the position that makes it work. Adding a
   component does not create this: you either vendor the token stylesheet here
   or let the theme registry item write its block into this file instead. */
@import "./opsinjs.css";

/* Tailwind v4 finds classes by scanning. The copied components live in your
   repository, so the directory holding them must be declared. */
@source "../components/opsinjs/**/*.{ts,tsx}";

/* Your overrides last, so they win. */
@theme {
  /* your brand extensions */
}
```

Four rules, in priority order:

1. **Tailwind first.** Everything else layers on top of it.
2. **The opsinjs token layer second.** It defines the custom properties every
   component reads. If it is imported before Tailwind, Tailwind's own preflight can
   overwrite parts of it.
3. **`@source` for every directory holding copied components.** Without this,
   Tailwind never sees the classes and generates none of them.
4. **Your overrides last.** Anything you want to win goes after the token layer,
   never inside it. Editing the token layer directly makes the next upgrade a
   manual merge for no benefit.

[Tailwind v4](../../theming/tailwind-v4.mdx) covers `@theme` versus `@theme inline`
and the other ordering hazards.

## Wire the theme mode [#wire-the-theme-mode]

There is no opsinjs provider. Light and dark are a class on the root element, and
the tokens resolve from there:

```tsx title="app/layout.tsx"
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>{children}</body>
    </html>
  )
}
```

Two details that matter more than they look:

* **`lang` is not optional.** Screen readers select a pronunciation dictionary from
  it, and a health interface reading numbers and units aloud in the wrong language
  is a real accessibility failure, not a cosmetic one.
* **`suppressHydrationWarning` on `<html>`** is what lets a theme script set the
  class before React hydrates without producing a mismatch warning. It suppresses
  the warning on that element only, not on your tree.

If you want a system-preference toggle, any theme library that sets a class on the
root element will do; opsinjs takes no opinion and adds no dependency.

## Render something [#render-something]

`StatusPill` is implemented, and this is the whole of its simplest use. The
level is a required prop with no default, and `describes` is what a screen
reader announces the level as being about. Without it a listener hears a
verdict with no subject:

```tsx title="app/page.tsx"
import { StatusPill } from "@/components/opsinjs/status-pill"

export default function Page() {
  return <StatusPill status="watch" describes="Blood pressure" />
}
```

`StatusPill` is display-only, so this stays a server component and ships no
JavaScript. Components that need a client boundary say so on their own pages;
[Framework support](../framework-support.mdx#know-where-the-client-boundary-falls)
explains which and why.

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

These are the checks that decide whether the install is correct, in the order
that isolates the most failures soonest.

1. **The page renders and the pill is styled.** Unstyled means the `@source` line
   is missing or points at the wrong directory.
2. **Inspect the pill's background.** It should resolve through a custom property
   in the opsinjs status scope. A literal colour means the token layer is missing
   or imported in the wrong position.
3. **Add `class="dark"` to `<html>` by hand.** Colours should invert with no
   JavaScript involved at all. If they do not, the token layer is not loading its
   dark block.
4. **Check the network tab.** A page containing only display components should
   ship no additional client JavaScript for them.
5. **Switch your display to greyscale.** The pill must still say which status it
   is, in words. If it does not, the component is being used wrongly rather than
   installed wrongly.

## Troubleshooting [#troubleshooting]

**Unstyled components.** The `@source` path is relative to the CSS file, and it is
easy to be one directory out. Confirm by putting a deliberate arbitrary class on a
copied component and checking whether it takes effect.

**Colours are wrong or fall back to defaults.** Import position. Move
`@import "./opsinjs.css"` directly after `@import "tailwindcss"`.

**A hydration warning about the theme class.** `suppressHydrationWarning` is
missing from `<html>`.

**`useState` errors in a server component.** You imported an interactive
component into a server tree. Mark the leaf as a client component, not the page.

**Pages Router.** Everything above applies except that there are no server
components, so the client-boundary section is moot. Import the stylesheet in
`pages/_app.tsx` and set the theme class in `pages/_document.tsx`.

**Turbopack versus webpack.** opsinjs adds no bundler configuration and needs
none. If a bundler-specific problem appears, it is a Tailwind or Next question
before it is an opsinjs one.

## Next [#next]

* [components.json](./components-json.mdx) covers every field opsinjs reads,
  annotated.
* [Quick start](../quick-start.mdx) covers the first real screen.
* [Tailwind v4](../../theming/tailwind-v4.mdx) has the ordering rules in full.
