Skip to content

shadcn.io is an independent project built on shadcn/ui

React Router

Set up shadcn.io in a React Router 7 app: create one with our preset, or run init in yours. Components import from ~/components/ui, as its alias says.

React Router 7 in framework mode is Remix's successor: routes with loaders and actions, server rendering, built on Vite. Its starter already has Tailwind CSS v4 and an import alias, ~/, so setup is init and a first component.

Create a new app

Run init with the React Router template

shadcn CLILocked
Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

The CLI asks for a project name and creates the app from shadcn's React Router starter (React Router 7, React 19, Vite 8, Tailwind CSS v4), then adds our theme to app/app.css. Add --monorepo for a workspace with the app in apps/web and the components in packages/ui.

Add a component

shadcn CLILocked
Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

The starter's home route already renders a Button: answer yes when the CLI asks to overwrite button.tsx.

Use it in a route

app/routes/home.tsx
import { Button } from "~/components/ui/button";

export default function Home() {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Button>Click me</Button>
    </div>
  );
}

Add it to an existing app

Create the app, if you need one

bunx --bun create-react-router@latest my-app

Its default template sets up Tailwind CSS v4 (in app/app.css, through @tailwindcss/vite) and the ~/* alias, which points at app/.

Check the alias

An app from an older template may not have it. Add it to tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "~/*": ["./app/*"]
    }
  }
}

Vite resolves it too when vite.config.ts has resolve: { tsconfigPaths: true } (Vite 8) or the vite-tsconfig-paths plugin, as the templates do.

Run init

shadcn CLILocked
Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

The CLI reads the ~/* alias, so components go to app/components/ui and import as ~/components/ui/…. The theme goes into app/app.css, which app/root.tsx imports.

Add a component and use it

shadcn CLILocked
Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

Import it from ~/components/ui/dialog in any route.

Data from a loader

Components render on the server and hydrate in the browser, so they take a loader's data like any other markup. A table of invoices from a loader:

app/routes/invoices.tsx
import type { Route } from "./+types/invoices";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "~/components/ui/table";

export async function loader() {
  return {
    invoices: [
      { id: "INV001", status: "Paid", total: "$250.00" },
      { id: "INV002", status: "Pending", total: "$150.00" },
    ],
  };
}

export default function Invoices({ loaderData }: Route.ComponentProps) {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-end">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {loaderData.invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableCell>{invoice.id}</TableCell>
            <TableCell>{invoice.status}</TableCell>
            <TableCell className="text-end">{invoice.total}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Register the route in app/routes.ts with route("invoices", "routes/invoices.tsx"). For forms that post to an action, build them with Field, as the Form guide shows.

FAQ

Why do imports start with ~/ instead of @/?

React Router's templates name their alias ~/*, pointing at app/. init reads the alias from tsconfig.json and writes components with it, so you import ~/components/ui/button, the same way the app's own files import each other.

Does it work in SPA mode, with ssr set to false?

Yes. The components don't depend on server rendering: with ssr: false in react-router.config.ts, they render in the browser like in a Vite app. Setup is the same.

I use React Router as a library, not the framework. Which guide is mine?

Declarative or data mode inside your own bundler: follow the guide for that bundler, usually Vite. The components don't care which router renders them.

Is this the guide for Remix?

Yes, for new apps. Remix v2's framework became React Router 7, and new projects start here. A Remix v2 app has an upgrade path first: see the Remix guide.

Why do I see a hydration warning with dark mode?

The dark class is set in the browser before React hydrates, so the server's <html> differs from the client's. Set the theme from a cookie in the root loader so the server renders the same class, or add suppressHydrationWarning to <html> in app/root.tsx.

Where does the theme go?

Into app/app.css, the stylesheet the template imports from app/root.tsx. init finds it from components.json's tailwind.css setting, which it fills in on the first run.

How do I submit a form to an action with these components?

Render React Router's <Form method="post"> and put Field, Input and Button inside it: they're regular form controls with a name, so the action reads them from request.formData(). Show the action's errors with FieldError.

Can I share the components between apps?

Yes: init -t react-router --monorepo creates a workspace with the app in apps/web and the components in packages/ui. Add components from apps/web, or with -c apps/web from the root.