# Next.js Component Library Setup for the App Router

> Next.js component library setup for the App Router, tested on Next.js 16.4: use client boundaries, tokens in globals.css, dark mode without a flash, updates.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 8 min
- Canonical: https://wingo-ui.com/blog/next-js-component-library-setup

## TL;DR

A component library fits the Next.js App Router when each interactive file starts with use client, nothing touches window while the module loads, colors live as CSS variables in globals.css and the theme is set before the first paint. In a new Next.js 16 app, remove the starter's prefers-color-scheme block, run npx wingo-ui@latest init, add components, then wrap the root layout in next-themes and UIProvider with suppressHydrationWarning on the html tag. Server pages import the components directly; event handlers move into small client files.

Most guides to adding a Next.js component library stop at the install command. The questions start right after it: which files need `"use client"`, why a server page cannot pass `onClick` to a button, why the dark theme flashes on reload, and why a theme toggle does nothing in a fresh app. This guide answers those first, then walks through a full Next.js component library setup with real commands, from `create-next-app` to updating components months later.

Everything below was run on October 13, 2026 with `create-next-app@latest` (Next.js 16.4.0, React 19.3.0, Tailwind CSS v4), the `wingo-ui` CLI 1.0.3 and next-themes 0.4.6. The library is ours, Wingo UI, but Part 1 applies to any source-owned library, including a shadcn Next.js App Router project. For the token side in depth, read the pillar on [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens).

## What makes a component library work well in the App Router?

Four properties. If a library has them, the App Router stays out of your way. If it does not, you end up writing wrappers.

### "use client" at the file that needs it

In the App Router every file is a server component until it says otherwise. A use client component library marks each interactive file at the top, so the boundary sits inside the library and your pages stay server components. Every Wingo UI file that uses state, effects or context starts with the directive, including [Button](https://wingo-ui.com/components/button), [Card](https://wingo-ui.com/components/card), [Dialog](https://wingo-ui.com/components/dialog) and the `UIProvider` in `lib/ui-config.tsx`.

That gives you one rule to remember: a server page can render these components and pass them strings, numbers, objects and JSX, but not functions. `<Button onClick={...}>` inside a server page fails, because React cannot send a function from the server to the browser. Move the handler into a small client file. Step 6 below shows the pattern.

### No window at module scope

Server rendering imports every module on the server, where `window`, `document` and `matchMedia` do not exist. A library that reads them while the module loads, or during render, crashes or renders something different on each side. Ours follows one rule: no `window` or `document` at module scope or during render. Browser reads happen in effects or in `useSyncExternalStore` with a server fallback, so `useIsMobile()` returns `false` on the server and updates after hydration. The [React use mobile hook guide](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) shows why that matters for layout.

### A theme without a flash

The server does not know whether the visitor picked dark mode, because that choice lives in `localStorage`. The fix is a tiny script that sets the class on `<html>` before the first paint. next-themes injects that script for you. React then sees an `<html>` element that differs from the server HTML, which is why the root layout carries `suppressHydrationWarning`. More on that in step 5, and in our [Next.js and Tailwind CSS guides](https://wingo-ui.com/blog/category/nextjs-tailwind).

### Tokens in CSS, not in a JavaScript runtime

Colors, radii, shadows and motion timings are CSS variables in `globals.css`, mapped to utilities with `@theme inline`. Dark mode swaps the variables under `.dark`. Nothing has to run in JavaScript to know what `bg-surface` means, so server components render the right colors and there is no theme provider to wait for. This is what makes Next.js Tailwind v4 components cheap to theme: you edit one file.

## How do I set up a Next.js component library, step by step?

### 1. Create the app

Skip this in an existing project that already runs Tailwind CSS v4.

```bash
npx create-next-app@latest my-app --ts --tailwind --eslint --app --import-alias "@/*"
cd my-app
```

On 16.4.0 this writes `app/globals.css` with `@import "tailwindcss";`, a `tsconfig.json` with the `@/*` alias, and a dark palette inside `@media (prefers-color-scheme: dark)`.

### 2. Remove the starter's dark media block

This step is easy to miss. When `wingo-ui init` finds a `prefers-color-scheme` block, it respects it and puts the library's dark values there too. That is right for an app that only follows the system setting, but next-themes toggles a `.dark` class, and a media query ignores that class. Delete this block from `app/globals.css` before you run init:

```css
@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}
```

With the block gone, init writes a `.dark` block and `@custom-variant dark (&:is(.dark *));` instead. We ran init both ways: with the block it reported `dark mode: media`, without it `dark mode: class`. The [theming docs](https://wingo-ui.com/docs/theming) describe both modes.

### 3. Run init

```bash
npx wingo-ui@latest init
```

The CLI detects the framework from `package.json`, the `@/*` alias from `tsconfig.json` and the stylesheet that imports Tailwind. Then it:

- installs `clsx` and `tailwind-merge` and creates `lib/utils.ts` with the `cn` helper,
- merges the design tokens into `app/globals.css`, adding only names you do not have yet, so `--background` and `--foreground` from the starter stay,
- writes `wingo-ui.json` with the framework, aliases, stylesheet and package manager.

Our run printed `added 72 variables, 46 dark values, 72 @theme mappings, @custom-variant dark, base border color (dark mode: class)`. With `--yes` it skips the prompt that offers to connect a coding agent.

### 4. Add components

```bash
npx wingo-ui@latest add button dialog card
```

Files land in `components/ui`, `hooks` and `lib`. The command pulls what the three items import: the [Drawer](https://wingo-ui.com/components/drawer) that Dialog becomes on phones, `lib/motion.ts`, `lib/tones.ts`, `lib/ui-config.tsx` and four hooks. It also installs the npm packages (`@radix-ui/react-dialog`, `motion`, `vaul`, `lucide-react` and a few more). Free items need no account or key.

`wingo-ui.json` now records each item with its version and a SHA-256 hash per file, and the CLI keeps the installed copies in `.wingo-ui/`. Commit both: they are what lets `update` merge new releases into files you edited.

### 5. Wrap the root layout

Install next-themes, then replace `app/layout.tsx`:

```bash
npm install next-themes
```

```tsx
// app/layout.tsx
import { ThemeProvider } from "next-themes"
import { UIProvider } from "@/lib/ui-config"
import "./globals.css"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
          <UIProvider locale="en-US" defaults={{ button: { radius: "full" } }}>
            {children}
          </UIProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
```

The layout stays a server component. `ThemeProvider` and `UIProvider` are client components, and the props passed to them are plain data, so this is allowed.

- `attribute="class"` makes next-themes set `class="dark"` on `<html>`, which the `.dark` tokens and the `dark:` variant follow.
- `suppressHydrationWarning` is needed because the theme script changes `<html>` before React hydrates. It applies to that element's own attributes only, one level deep, so it will not hide real mismatches in your pages.
- `disableTransitionOnChange` stops every color transition from animating at once when the theme flips.
- [`UIProvider`](https://wingo-ui.com/components/ui-config) sets app-wide defaults by component name and the locale for dates and numbers. Here every button is fully rounded unless a button sets `radius` itself; explicit props always win.

### 6. Use a client component inside a server page

Pages stay async server components that fetch data. Components with no handlers render straight from the page, and JSX can travel through props, so a whole dialog with a trigger and a footer works without any client file of your own:

```tsx
// app/page.tsx
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose } from "@/components/ui/dialog"
import { InviteForm } from "./invite-form"

async function getProject() {
  return { name: "Website redesign", members: 4 }
}

export default async function Page() {
  const project = await getProject()

  return (
    <main className="mx-auto flex max-w-xl flex-col gap-6 p-6">
      <h1 className="text-xl font-semibold">{project.name}</h1>
      <Dialog
        trigger={<Button tone="brand">Invite people</Button>}
        title="Invite people"
        description={`They get access to ${project.name}.`}
        footer={
          <DialogClose asChild>
            <Button variant="soft">Done</Button>
          </DialogClose>
        }
      >
        <InviteForm />
      </Dialog>
    </main>
  )
}
```

The part with state and an `onClick` goes in its own file:

```tsx
// app/invite-form.tsx
"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"

export function InviteForm() {
  const [sent, setSent] = useState(false)
  return (
    <Button variant="outline" onClick={() => setSent(true)} disabled={sent}>
      {sent ? "Invite sent" : "Send invite to emma@example.com"}
    </Button>
  )
}
```

Both files pass `tsc --noEmit` and ESLint with the config `create-next-app` generates. The dialog below is the same component with a form inside. Try it on a narrow screen too.

> Live demo (Dialog): Press Edit to open the dialog. Below 768px the same component opens as a bottom sheet you can drag down to close. Try it at [Dialog](https://wingo-ui.com/components/dialog) and install it with `npx wingo-ui@latest add dialog`.

### 7. Add a theme toggle

[Theme Toggle](https://wingo-ui.com/components/theme-toggle) is a Pro component: an animated sun and moon button, a segmented control or a dropdown, all reading next-themes. On the free tier, a short client file does the job:

```tsx
// components/theme-switch.tsx
"use client"

import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"

export function ThemeSwitch() {
  const { resolvedTheme, setTheme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      aria-label="Toggle theme"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      <Moon className="dark:hidden" />
      <Sun className="hidden dark:block" />
    </Button>
  )
}
```

The icons switch with CSS, so the server HTML and the first client render match and there is no mounted flag to manage. The common causes of the hydration warning are covered in our [next-themes hydration error fix](https://wingo-ui.com/blog/next-themes-hydration-error), and the variant and tokens behind the toggle in our [Tailwind v4 dark mode guide](https://wingo-ui.com/blog/tailwind-v4-dark-mode). More posts live in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind).

### 8. Sign in for Pro components

Pro items need a key. Sign the CLI in once per machine:

```bash
npx wingo-ui@latest login
```

It opens the browser to approve the device and saves a key under `~/.config/wingo-ui`. On a server or in CI, pass a key from your account instead:

```bash
npx wingo-ui@latest login --key wui_...
# or
export WINGO_UI_API_KEY=wui_...
```

Without a key, adding a Pro item stops with a message that names the item and links the plans. The plans are on the [pricing page](https://wingo-ui.com/pricing).

### 9. Keep components up to date

```bash
npx wingo-ui@latest outdated      # what changed upstream
npx wingo-ui@latest diff button   # your file against the latest release
npx wingo-ui@latest update        # merge new releases into your copies
```

We edited one label in an installed file and ran `diff`: it printed a unified diff of our change against upstream. `update` does a three-way merge with the copy saved in `.wingo-ui/`, keeps your edits, and marks real conflicts like git does. The [CLI docs](https://wingo-ui.com/docs/cli) list every flag.

## What should I check when something looks wrong?

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Theme toggle changes nothing | Dark values sit in a `prefers-color-scheme` media block | Step 2, then run init again |
| "Functions cannot be passed directly to Client Components" | `onClick` passed from a server page | Move the handler into a client file (step 6) |
| Hydration warning on `<html>` | `suppressHydrationWarning` missing | Add it to `<html>` only |
| Components unstyled, `bg-surface` does nothing | Tokens not in the stylesheet Tailwind reads | Run `npx wingo-ui@latest init` again; it adds only missing tokens |
| Pro item fails to install | No key on this machine | `npx wingo-ui@latest login` or `WINGO_UI_API_KEY` |

## Where to go next

Start with the free basics: `npx wingo-ui@latest add button dialog card`, then follow the [installation docs](https://wingo-ui.com/docs/installation) for the shadcn CLI and manual paths. If you write UI with Claude Code, Codex or Cursor, connect the [MCP server](https://wingo-ui.com/docs/mcp) so the agent searches the library before it writes a component. Building on Vite instead of Next.js? The [shadcn Vite setup](https://wingo-ui.com/blog/shadcn-vite-setup) covers the same steps there.

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [Dialog](https://wingo-ui.com/components/dialog) (Free): A React modal dialog for focused tasks: a spring-scaled card on desktop and a bottom sheet on phones, with nesting and scrolling. Install: `npx wingo-ui@latest add dialog`
- [UIProvider](https://wingo-ui.com/components/ui-config) (Free): A React config provider for Wingo UI: app-wide default props for every component, the Intl locale and a reduced-motion policy. Install: `npx wingo-ui@latest add ui-config`
- [Theme Toggle](https://wingo-ui.com/components/theme-toggle) (Pro): A React theme toggle for light, dark and system mode on next-themes: an animated sun and moon button, a segmented control or a menu. Install: `npx wingo-ui@latest add theme-toggle`

## FAQ

### Can I use a client component library in Next.js server components?

Yes. A server page can import and render a file marked use client and pass it strings, numbers and JSX. It cannot pass functions such as onClick, so put handlers in a small client component and render that from the page.

### Why does the root layout need suppressHydrationWarning?

next-themes runs a script before React hydrates that adds the dark or light class to the html element. The server HTML did not have that class, so React would warn about a mismatch. The attribute silences the warning on that one element only.

### Why does my theme toggle not switch colors after wingo-ui init?

The create-next-app stylesheet ships a @media (prefers-color-scheme: dark) block, and init puts the dark values there when it finds one. Delete that block before running init, or move the dark values into a .dark block and add @custom-variant dark (&:is(.dark *)); so the class from next-themes wins.

### Do free Wingo UI components need an account or API key?

No. Free items such as button, card and dialog install with npx wingo-ui@latest add and no key. Pro items need npx wingo-ui@latest login once per machine, or WINGO_UI_API_KEY in CI.

### Does this setup work with Next.js 15?

We ran every step on Next.js 16.4.0. The library needs React 19 and Tailwind CSS v4, which a Next.js 15 App Router project can also use, but we did not rerun the steps there.

---

Source: https://wingo-ui.com/blog/next-js-component-library-setup
