diff --git a/apps/v4/app/(create)/components/project-form.tsx b/apps/v4/app/(create)/components/project-form.tsx
index 3ca5c1ce94..f02ef6e9bd 100644
--- a/apps/v4/app/(create)/components/project-form.tsx
+++ b/apps/v4/app/(create)/components/project-form.tsx
@@ -57,6 +57,11 @@ const TEMPLATES = [
title: "TanStack Start",
logo: '',
},
+ {
+ value: "react-router",
+ title: "React Router",
+ logo: '',
+ },
{
value: "vite",
title: "Vite",
@@ -170,10 +175,11 @@ export function ProjectForm() {
| "next"
| "next-monorepo"
| "start"
+ | "react-router"
| "vite",
})
}}
- className="grid grid-cols-2 gap-2"
+ className="grid grid-cols-3 gap-2"
>
{TEMPLATES.map((template) => (
diff --git a/apps/v4/app/(create)/lib/search-params.ts b/apps/v4/app/(create)/lib/search-params.ts
index ab00904568..ad0d7ba204 100644
--- a/apps/v4/app/(create)/lib/search-params.ts
+++ b/apps/v4/app/(create)/lib/search-params.ts
@@ -65,6 +65,7 @@ const designSystemSearchParams = {
"next",
"next-monorepo",
"start",
+ "react-router",
"vite",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
diff --git a/apps/v4/registry/config.ts b/apps/v4/registry/config.ts
index bfe1e20b6e..95c2e1cc42 100644
--- a/apps/v4/registry/config.ts
+++ b/apps/v4/registry/config.ts
@@ -95,7 +95,7 @@ export const designSystemConfigSchema = z
.enum(RADII.map((r) => r.name) as [RadiusValue, ...RadiusValue[]])
.default("default"),
template: z
- .enum(["next", "next-monorepo", "start", "vite"])
+ .enum(["next", "next-monorepo", "start", "react-router", "vite"])
.default("next")
.optional(),
})
diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts
index 641c4f10c0..b261cb0fe5 100644
--- a/packages/shadcn/src/commands/init.ts
+++ b/packages/shadcn/src/commands/init.ts
@@ -71,7 +71,7 @@ export const init = new Command()
.argument("[components...]", "names, url or local path to component")
.option(
"-t, --template ",
- "the template to use. (next, start, vite, next-monorepo)"
+ "the template to use. (next, start, vite, next-monorepo, react-router)"
)
.option("-p, --preset [name]", "use a preset configuration")
.option("-y, --yes", "skip confirmation prompt.", true)
diff --git a/packages/shadcn/src/templates/index.ts b/packages/shadcn/src/templates/index.ts
index 1e04438f80..8cf3d15026 100644
--- a/packages/shadcn/src/templates/index.ts
+++ b/packages/shadcn/src/templates/index.ts
@@ -1,5 +1,6 @@
import { next } from "./next"
import { nextMonorepo } from "./next-monorepo"
+import { reactRouter } from "./react-router"
import { start } from "./start"
import { vite } from "./vite"
@@ -10,6 +11,7 @@ export const templates = {
next,
vite,
start,
+ "react-router": reactRouter,
"next-monorepo": nextMonorepo,
}
diff --git a/packages/shadcn/src/templates/react-router.ts b/packages/shadcn/src/templates/react-router.ts
new file mode 100644
index 0000000000..ab3278a978
--- /dev/null
+++ b/packages/shadcn/src/templates/react-router.ts
@@ -0,0 +1,109 @@
+import os from "os"
+import path from "path"
+import { handleError } from "@/src/utils/handle-error"
+import { spinner } from "@/src/utils/spinner"
+import dedent from "dedent"
+import { execa } from "execa"
+import fs from "fs-extra"
+
+import { GITHUB_TEMPLATE_URL, createTemplate } from "./create-template"
+
+export const reactRouter = createTemplate({
+ name: "react-router",
+ title: "React Router",
+ defaultProjectName: "react-router-app",
+ frameworks: ["react-router"],
+ scaffold: async ({ projectPath, packageManager }) => {
+ const createSpinner = spinner(
+ `Creating a new React Router project. This may take a few minutes.`
+ ).start()
+
+ try {
+ const localTemplateDir = process.env.SHADCN_TEMPLATE_DIR
+ if (localTemplateDir) {
+ // Use local template directory for development.
+ const localTemplatePath = path.resolve(
+ localTemplateDir,
+ "react-router-app"
+ )
+ await fs.copy(localTemplatePath, projectPath, {
+ filter: (src) => !src.includes("node_modules"),
+ })
+ } else {
+ // Get the template from GitHub.
+ const templatePath = path.join(
+ os.tmpdir(),
+ `shadcn-template-${Date.now()}`
+ )
+ await fs.ensureDir(templatePath)
+ const response = await fetch(GITHUB_TEMPLATE_URL)
+ if (!response.ok) {
+ throw new Error(`Failed to download template: ${response.statusText}`)
+ }
+
+ // Write the tar file.
+ const tarPath = path.resolve(templatePath, "template.tar.gz")
+ await fs.writeFile(tarPath, Buffer.from(await response.arrayBuffer()))
+ await execa("tar", [
+ "-xzf",
+ tarPath,
+ "-C",
+ templatePath,
+ "--strip-components=2",
+ "ui-main/templates/react-router-app",
+ ])
+ const extractedPath = path.resolve(templatePath, "react-router-app")
+ await fs.move(extractedPath, projectPath)
+ await fs.remove(templatePath)
+ }
+
+ // Remove pnpm-lock.yaml if using a different package manager.
+ if (packageManager !== "pnpm") {
+ const lockFilePath = path.join(projectPath, "pnpm-lock.yaml")
+ if (fs.existsSync(lockFilePath)) {
+ await fs.remove(lockFilePath)
+ }
+ }
+
+ // Run install.
+ await execa(packageManager, ["install"], {
+ cwd: projectPath,
+ })
+
+ // Write project name to the package.json.
+ const packageJsonPath = path.join(projectPath, "package.json")
+ if (fs.existsSync(packageJsonPath)) {
+ const packageJsonContent = await fs.readFile(packageJsonPath, "utf8")
+ const packageJson = JSON.parse(packageJsonContent)
+ packageJson.name = projectPath.split("/").pop()
+ await fs.writeFile(
+ packageJsonPath,
+ JSON.stringify(packageJson, null, 2)
+ )
+ }
+
+ createSpinner?.succeed("Creating a new React Router project.")
+ } catch (error) {
+ createSpinner?.fail(
+ "Something went wrong creating a new React Router project."
+ )
+ handleError(error)
+ }
+ },
+ create: async () => {
+ // Empty for now.
+ },
+ files: [
+ {
+ type: "registry:file",
+ path: "app/routes/home.tsx",
+ target: "app/routes/home.tsx",
+ content: dedent`import { ComponentExample } from "@/components/component-example";
+
+export default function Home() {
+ return ;
+}
+`,
+ },
+ ],
+})
diff --git a/templates/react-router-app/.dockerignore b/templates/react-router-app/.dockerignore
new file mode 100644
index 0000000000..9b8d514712
--- /dev/null
+++ b/templates/react-router-app/.dockerignore
@@ -0,0 +1,4 @@
+.react-router
+build
+node_modules
+README.md
\ No newline at end of file
diff --git a/templates/react-router-app/.gitignore b/templates/react-router-app/.gitignore
new file mode 100644
index 0000000000..039ee62d21
--- /dev/null
+++ b/templates/react-router-app/.gitignore
@@ -0,0 +1,7 @@
+.DS_Store
+.env
+/node_modules/
+
+# React Router
+/.react-router/
+/build/
diff --git a/templates/react-router-app/.prettierrc b/templates/react-router-app/.prettierrc
new file mode 100644
index 0000000000..e76c16d8a2
--- /dev/null
+++ b/templates/react-router-app/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "endOfLine": "lf",
+ "semi": false,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 80,
+ "plugins": ["prettier-plugin-tailwindcss"],
+ "tailwindStylesheet": "app/app.css",
+ "tailwindFunctions": ["cn", "cva"]
+}
diff --git a/templates/react-router-app/Dockerfile b/templates/react-router-app/Dockerfile
new file mode 100644
index 0000000000..207bf937e3
--- /dev/null
+++ b/templates/react-router-app/Dockerfile
@@ -0,0 +1,22 @@
+FROM node:20-alpine AS development-dependencies-env
+COPY . /app
+WORKDIR /app
+RUN npm ci
+
+FROM node:20-alpine AS production-dependencies-env
+COPY ./package.json package-lock.json /app/
+WORKDIR /app
+RUN npm ci --omit=dev
+
+FROM node:20-alpine AS build-env
+COPY . /app/
+COPY --from=development-dependencies-env /app/node_modules /app/node_modules
+WORKDIR /app
+RUN npm run build
+
+FROM node:20-alpine
+COPY ./package.json package-lock.json /app/
+COPY --from=production-dependencies-env /app/node_modules /app/node_modules
+COPY --from=build-env /app/build /app/build
+WORKDIR /app
+CMD ["npm", "run", "start"]
\ No newline at end of file
diff --git a/templates/react-router-app/README.md b/templates/react-router-app/README.md
new file mode 100644
index 0000000000..10256680fb
--- /dev/null
+++ b/templates/react-router-app/README.md
@@ -0,0 +1,21 @@
+# React Router + shadcn/ui
+
+This is a template for a new React Router project with React, TypeScript, and shadcn/ui.
+
+## Adding components
+
+To add components to your app, run the following command:
+
+```bash
+npx shadcn@latest add button
+```
+
+This will place the ui components in the `components` directory.
+
+## Using components
+
+To use the components in your app, import them as follows:
+
+```tsx
+import { Button } from "@/components/ui/button";
+```
diff --git a/templates/react-router-app/app/app.css b/templates/react-router-app/app/app.css
new file mode 100644
index 0000000000..f1d8c73cdc
--- /dev/null
+++ b/templates/react-router-app/app/app.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/templates/react-router-app/app/root.tsx b/templates/react-router-app/app/root.tsx
new file mode 100644
index 0000000000..43f7bfe738
--- /dev/null
+++ b/templates/react-router-app/app/root.tsx
@@ -0,0 +1,62 @@
+import {
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ isRouteErrorResponse,
+} from "react-router"
+
+import type { Route } from "./+types/root"
+import "./app.css"
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export default function App() {
+ return
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "Oops!"
+ let details = "An unexpected error occurred."
+ let stack: string | undefined
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "Error"
+ details =
+ error.status === 404
+ ? "The requested page could not be found."
+ : error.statusText || details
+ } else if (import.meta.env.DEV && error && error instanceof Error) {
+ details = error.message
+ stack = error.stack
+ }
+
+ return (
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+ )
+}
diff --git a/templates/react-router-app/app/routes.ts b/templates/react-router-app/app/routes.ts
new file mode 100644
index 0000000000..b5829b0d7f
--- /dev/null
+++ b/templates/react-router-app/app/routes.ts
@@ -0,0 +1,3 @@
+import { type RouteConfig, index } from "@react-router/dev/routes"
+
+export default [index("routes/home.tsx")] satisfies RouteConfig
diff --git a/templates/react-router-app/app/routes/home.tsx b/templates/react-router-app/app/routes/home.tsx
new file mode 100644
index 0000000000..2d437c3360
--- /dev/null
+++ b/templates/react-router-app/app/routes/home.tsx
@@ -0,0 +1,7 @@
+export default function Home() {
+ return (
+
+ )
+}
diff --git a/templates/react-router-app/package.json b/templates/react-router-app/package.json
new file mode 100644
index 0000000000..a5ec9c779c
--- /dev/null
+++ b/templates/react-router-app/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "rr-app",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "react-router build",
+ "dev": "react-router dev",
+ "start": "react-router-serve ./build/server/index.js",
+ "typecheck": "react-router typegen && tsc",
+ "format": "prettier --write \"**/*.{ts,tsx}\""
+ },
+ "dependencies": {
+ "@react-router/node": "7.12.0",
+ "@react-router/serve": "7.12.0",
+ "isbot": "^5.1.31",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-router": "7.12.0"
+ },
+ "devDependencies": {
+ "@react-router/dev": "7.12.0",
+ "@tailwindcss/vite": "^4.1.13",
+ "@types/node": "^22",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "prettier": "^3.8.1",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "tailwindcss": "^4.1.13",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.7",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/templates/react-router-app/public/favicon.ico b/templates/react-router-app/public/favicon.ico
new file mode 100644
index 0000000000..5dbdfcddcb
Binary files /dev/null and b/templates/react-router-app/public/favicon.ico differ
diff --git a/templates/react-router-app/react-router.config.ts b/templates/react-router-app/react-router.config.ts
new file mode 100644
index 0000000000..ade263370b
--- /dev/null
+++ b/templates/react-router-app/react-router.config.ts
@@ -0,0 +1,7 @@
+import type { Config } from "@react-router/dev/config"
+
+export default {
+ // Config options...
+ // Server-side render by default, to enable SPA mode set this to `false`
+ ssr: true,
+} satisfies Config
diff --git a/templates/react-router-app/tsconfig.json b/templates/react-router-app/tsconfig.json
new file mode 100644
index 0000000000..dc391a45f7
--- /dev/null
+++ b/templates/react-router-app/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "include": [
+ "**/*",
+ "**/.server/**/*",
+ "**/.client/**/*",
+ ".react-router/types/**/*"
+ ],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"],
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "rootDirs": [".", "./.react-router/types"],
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "esModuleInterop": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true
+ }
+}
diff --git a/templates/react-router-app/vite.config.ts b/templates/react-router-app/vite.config.ts
new file mode 100644
index 0000000000..7f62744caf
--- /dev/null
+++ b/templates/react-router-app/vite.config.ts
@@ -0,0 +1,8 @@
+import { reactRouter } from "@react-router/dev/vite"
+import tailwindcss from "@tailwindcss/vite"
+import { defineConfig } from "vite"
+import tsconfigPaths from "vite-tsconfig-paths"
+
+export default defineConfig({
+ plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
+})