setup-tanstack-start
Bootstrap a new web project with TanStack Start, React, Tailwind CSS v4, and shadcn/ui on top of the base tooling stack. Consult this skill whenever creating a web app, setting up a frontend project, starting a React application, or initializing anything involving TanStack Start, TanStack Router, TanStack Query, Tailwind, shadcn, or Vite.
Best use case
setup-tanstack-start is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Bootstrap a new web project with TanStack Start, React, Tailwind CSS v4, and shadcn/ui on top of the base tooling stack. Consult this skill whenever creating a web app, setting up a frontend project, starting a React application, or initializing anything involving TanStack Start, TanStack Router, TanStack Query, Tailwind, shadcn, or Vite.
Teams using setup-tanstack-start should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/setup-tanstack-start/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How setup-tanstack-start Compares
| Feature / Agent | setup-tanstack-start | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Bootstrap a new web project with TanStack Start, React, Tailwind CSS v4, and shadcn/ui on top of the base tooling stack. Consult this skill whenever creating a web app, setting up a frontend project, starting a React application, or initializing anything involving TanStack Start, TanStack Router, TanStack Query, Tailwind, shadcn, or Vite.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Setup
Bootstrap a new web project on top of the base tooling stack.
Adds: TanStack Start + Router + Query + Devtools, Tailwind CSS v4, React with Vite, shadcn/ui
## Why This Stack
- **TanStack Start** — Full-stack React framework with SSR, built on Vite and Nitro. Provides file-based routing, server functions, and streaming out of the box.
- **TanStack Router** — Type-safe routing with built-in search param validation, code splitting, and preloading.
- **TanStack Query** — Async server-state management with automatic caching, deduplication, and background refetching. SSR integration with Router streams prefetched queries to the client.
- **Tailwind CSS v4** — Utility-first CSS with a new engine that's faster and uses standard CSS syntax for configuration.
- **shadcn/ui** — Copy-paste component library that gives full ownership of the code. Components are customized to project conventions after installation.
## Steps
### 1. Update package.json
```json
{
"scripts": {
"build": "vite build",
"dev": "vite dev",
"preview": "vite preview",
"e2e": "playwright test",
"validate": "bun run build && bun run lint && bun run types && bun run test && bun run unused"
},
"knip": {
"ignore": ["src/components/ui/**"]
}
}
```
### 2. Install dependencies
```bash
bun add @base-ui/react @tailwindcss/vite @tanstack/react-devtools @tanstack/react-form @tanstack/react-form-devtools @tanstack/react-query @tanstack/react-query-devtools @tanstack/react-router @tanstack/react-router-devtools @tanstack/react-router-ssr-query @tanstack/react-start react react-dom tailwindcss vite-tsconfig-paths
```
```bash
bun add -d @playwright/test @tanstack/devtools-vite @types/react @types/react-dom @vitejs/plugin-react @vitest/browser-playwright nitro vite vitest-browser-react
```
### 2.1. Install Playwright browsers
```bash
bunx playwright install chromium
```
### 3. Update tsconfig.json
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
```
The `paths` mapping is required because shadcn's CLI resolves `@/` literally when generating import paths. Without it, components end up in a physical `@/` directory instead of `src/`.
### 4. Update biome.jsonc
```jsonc
{
"extends": ["ultracite/core", "ultracite/react"],
"files": {
"includes": ["**", "!src/components/ui/**"]
},
"overrides": [
{
"includes": ["**/$*.ts", "**/$*.tsx"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": "off"
}
}
}
}
]
}
```
### 5. Update .gitignore
```
# tanstack
.nitro
.output
.tanstack
.vercel
.vinxi
dist
# playwright
playwright-report
test-results
```
### 6. vite.config.ts
```ts
import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import viteTsConfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [devtools(), viteTsConfigPaths(), tailwindcss(), tanstackStart(), nitro(), viteReact()],
});
```
### 7. Create vitest.config.ts
vitest.config.ts is separate from vite.config.ts because TanStack Start plugins (nitro, devtools, tanstackStart) should NOT run during tests.
Uses two projects to separate environments by file extension:
- `*.test.ts` → node (unit tests — has access to `process`, no DOM needed)
- `*.test.tsx` → browser mode (component tests — real Chromium via Playwright)
```ts
import { playwright } from "@vitest/browser-playwright";
import tailwindcss from "@tailwindcss/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [viteReact(), tailwindcss(), viteTsConfigPaths()],
test: {
projects: [
{
extends: true,
test: {
name: "unit",
include: ["src/**/*.test.ts"],
environment: "node",
},
},
{
extends: true,
optimizeDeps: {
exclude: [
"@tanstack/react-start",
"@tanstack/start-server-core",
"@tanstack/start-client-core",
],
},
test: {
name: "browser",
include: ["src/**/*.test.tsx"],
browser: {
provider: playwright(),
enabled: true,
instances: [{ browser: "chromium" }],
},
},
},
],
},
});
```
### 8. Create playwright.config.ts
```ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: {
baseURL: "http://localhost:3000",
},
webServer: {
command: "node .output/server/index.mjs",
port: 3000,
reuseExistingServer: true,
},
});
```
### 9. Create e2e/base.ts
E2E tests live in `e2e/` at the project root, separate from unit and component tests.
Create a custom Playwright fixture that waits for SSR hydration after every `page.goto()`. TanStack Start renders HTML on the server before React hydrates on the client — buttons and handlers are inert until hydration completes. This fixture makes all E2E tests wait automatically:
```ts
import { test as base } from "@playwright/test";
export const test = base.extend({
page: async ({ page }, use) => {
const originalGoto = page.goto.bind(page);
page.goto = async (...args) => {
const result = await originalGoto(...args);
await page.waitForSelector("[data-hydrated]", { timeout: 10_000 });
return result;
};
await use(page);
},
});
```
All E2E test files import `test` from this fixture and `expect` from `@playwright/test`:
```ts
import { expect } from "@playwright/test";
import { test } from "./base";
```
### 10. Update src/lib/env.ts
```ts
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
client: {},
clientPrefix: "VITE_",
emptyStringAsUndefined: true,
runtimeEnv: { ...process.env, ...import.meta.env },
server: {},
shared: {
DEV: z.boolean(),
},
});
```
### 10.1. Update src/lib/env.test.ts
Update the test from setup-base to verify the web-specific env configuration:
```ts
import { expect, test } from "vitest";
import { env } from "./env";
test("env initializes without error", () => {
expect(env).toBeDefined();
});
test("DEV is a boolean", () => {
expect(typeof env.DEV).toBe("boolean");
});
```
### 11. Set up shadcn/ui
The user configures their preset at `ui.shadcn.com/create` and copies the preset URL. Launch a **general-purpose subagent** (Task tool with `subagent_type: "general-purpose"`) to scaffold a temp project and extract the config files. Provide the preset URL and the project's CSS file path in the prompt.
The temp project approach is necessary because shadcn's `create` command generates a full project scaffold, but only a few config files are needed. Extracting them avoids polluting the existing project structure.
The subagent must:
1. **Create temp project:**
```bash
rm -rf .tmp && mkdir .tmp && bunx --bun shadcn@latest create tmp -p "<preset-url>" -t vite -c .tmp
```
> Always pass `-t vite -c .tmp` at the end. The CLI needs the explicit template flag and working directory even though the preset URL already encodes the template.
2. **Extract files** from `.tmp/tmp/`:
| Source (`.tmp/tmp/...`) | Destination | Action |
|-------------------------|-------------|--------|
| `components.json` | `./components.json` | Copy to project root. Update `tailwind.css` path to `src/styles/app.css` |
| `src/index.css` | `src/styles/app.css` | Copy as-is to `src/styles/app.css` |
| `src/lib/utils.ts` | `src/lib/utils.ts` | Copy if `cn()` utility doesn't exist yet |
| `package.json` | — | Read to identify new dependencies to install with `bun add` |
3. **Install dependencies** identified from the temp `package.json`.
4. **Clean up:**
```bash
rm -rf .tmp
```
### 12. Install base shadcn/ui components
Install `button`, `empty`, and `label`.
### 13. Create src/components/form.tsx
App-level form abstraction using TanStack Form and Base UI Field. Provides a `useAppForm` hook with pre-configured form and field components that integrate with shadcn's `Button` and `Label`.
```tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import {
type AnyFormApi,
createFormHook,
createFormHookContexts,
useStore,
} from "@tanstack/react-form";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel as FieldLabelPrimitive } from "@/components/ui/field";
const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
formComponents: {
Root: FormRoot,
Submit: FormSubmit,
},
fieldComponents: {
Root: FieldRoot,
Label: FieldLabel,
Control: FieldControl,
ErrorMessage: FieldErrorMessage,
},
});
function FormRoot({
form,
...props
}: React.ComponentProps<"form"> & {
form: AnyFormApi & {
AppForm: React.ComponentType<React.PropsWithChildren>;
};
}) {
return (
<form.AppForm>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
{...props}
/>
</form.AppForm>
);
}
function FormSubmit(props: Omit<React.ComponentProps<typeof Button>, "disabled" | "type">) {
const form = useFormContext();
const [isPristine, canSubmit, isSubmitting] = useStore(form.store, (state) => [
state.isPristine,
state.canSubmit,
state.isSubmitting,
]);
return <Button {...props} disabled={isPristine || !canSubmit || isSubmitting} type="submit" />;
}
function FieldRoot(props: React.ComponentProps<typeof Field>) {
const field = useFieldContext();
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return <Field data-invalid={isInvalid || undefined} {...props} />;
}
function FieldLabel(props: React.ComponentProps<typeof FieldLabelPrimitive>) {
const field = useFieldContext();
return <FieldLabelPrimitive htmlFor={field.name} {...props} />;
}
function FieldControl({ render, ...props }: useRender.ComponentProps<"input">) {
const form = useFormContext();
const field = useFieldContext<string>();
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return useRender({
render,
defaultTagName: "input",
props: mergeProps<"input">(
{
id: field.name,
disabled: isSubmitting,
"aria-invalid": isInvalid || undefined,
value: field.state.value,
onBlur: field.handleBlur,
onChange: ((eventOrValue: React.ChangeEvent<HTMLInputElement> | string) => {
const value = typeof eventOrValue === "string" ? eventOrValue : eventOrValue.target.value;
field.handleChange(value);
}) as React.ChangeEventHandler<HTMLInputElement>,
},
props
),
});
}
function FieldErrorMessage(props: Omit<React.ComponentProps<typeof FieldError>, "errors">) {
const field = useFieldContext();
const { errors } = field.state.meta;
if (errors.length === 0) {
return null;
}
return <FieldError errors={errors} {...props} />;
}
```
### 13.1. Create src/components/form.test.tsx
```tsx
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
import { expect, test, vi } from "vitest";
import { z } from "zod";
import { useAppForm } from "./form";
function TestForm({ onSubmit = vi.fn() }: { onSubmit?: (data: { email: string }) => void }) {
const form = useAppForm({
defaultValues: { email: "" },
validators: {
onSubmit: z.object({ email: z.string().email("Invalid email") }),
},
onSubmit: ({ value }) => onSubmit(value),
});
return (
<form.Root form={form}>
<form.AppField name="email">
{(field) => (
<field.Root>
<field.Label>Email</field.Label>
<field.Control />
<field.ErrorMessage />
</field.Root>
)}
</form.AppField>
<form.Submit>Submit</form.Submit>
</form.Root>
);
}
test("submit button is disabled when form is pristine", async () => {
render(<TestForm />);
await expect.element(page.getByRole("button", { name: "Submit" })).toBeDisabled();
});
test("submit button is enabled after valid input", async () => {
render(<TestForm />);
await page.getByLabelText("Email").fill("alice@test.com");
await expect.element(page.getByRole("button", { name: "Submit" })).toBeEnabled();
});
test("shows error message for invalid input", async () => {
render(<TestForm />);
await page.getByLabelText("Email").fill("not-an-email");
await expect.element(page.getByText("Invalid email")).toBeInTheDocument();
});
test("calls onSubmit with form data", async () => {
const onSubmit = vi.fn();
render(<TestForm onSubmit={onSubmit} />);
await page.getByLabelText("Email").fill("alice@test.com");
await page.getByRole("button", { name: "Submit" }).click();
expect(onSubmit).toHaveBeenCalledWith({ email: "alice@test.com" });
});
```
### 14. src/router.tsx
```tsx
import { QueryClient } from "@tanstack/react-query";
import { createRouter, type ErrorComponentProps, Link } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { Button } from "./components/ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./components/ui/empty";
import { env } from "./lib/env";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultErrorComponent: DefaultErrorComponent,
defaultNotFoundComponent: DefaultNotFoundComponent,
context: { queryClient },
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});
return router;
}
function DefaultErrorComponent({ error }: ErrorComponentProps) {
return (
<ErrorLayout
description={env.DEV ? error.message : "An unexpected error occurred"}
title="Something went wrong"
/>
);
}
function DefaultNotFoundComponent() {
return (
<ErrorLayout
description="The page you are looking for does not exist."
title="Page not found"
/>
);
}
function ErrorLayout({
title,
description,
}: {
title: React.ReactNode;
description: React.ReactNode;
}) {
return (
<div className="grid min-h-svh place-items-center px-4">
<Empty className="max-w-lg border">
<EmptyHeader>
<EmptyMedia variant="icon">
{/* Import and render a warning/error icon from the project's icon library */}
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
<Button nativeButton={false} render={<Link to="/" />}>
Go home
</Button>
</Empty>
</div>
);
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
```
### 15. src/routes/__root.tsx
```tsx
import type { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import { lazy, Suspense, useEffect } from "react";
import { env } from "../lib/env";
import appCss from "../styles/app.css?url";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "<AppName>",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
component: RootComponent,
shellComponent: RootDocument,
});
function RootComponent() {
return <Outlet />;
}
const Devtools = lazy(() =>
import("../components/devtools").then((mod) => ({ default: mod.Devtools })),
);
function RootDocument({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.body.dataset.hydrated = "";
}, []);
return (
<html className="dark" lang="en">
<head>
<HeadContent />
</head>
<body className="bg-background font-sans text-foreground antialiased">
{children}
{env.DEV && (
<Suspense>
<Devtools />
</Suspense>
)}
<Scripts />
</body>
</html>
);
}
```
### 16. src/components/devtools.tsx
```tsx
import { TanStackDevtools } from "@tanstack/react-devtools";
import { formDevtoolsPlugin } from "@tanstack/react-form-devtools";
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
export function Devtools() {
return (
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "TanStack Router",
render: <TanStackRouterDevtoolsPanel />,
},
{
name: "TanStack Query",
render: <ReactQueryDevtoolsPanel />,
},
formDevtoolsPlugin(),
]}
/>
);
}
```
### 17. src/routes/index.tsx
```tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<main className="grid min-h-svh place-items-center">
<h1 className="text-4xl font-bold"><AppName></h1>
</main>
);
}
```
### 18. Set up dark mode in src/styles/app.css
shadcn generates a `@custom-variant dark` line and separate `:root` / `.dark` blocks. Replace them to support three modes: light, dark, and system (auto).
#### 18.1 Replace the `@custom-variant dark` line
```css
/* Before (generated by shadcn) */
@custom-variant dark (&:is(.dark *));
/* After */
@custom-variant dark {
&:is(.dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:is(.auto *) {
@slot;
}
}
}
```
This makes Tailwind's `dark:` utilities work for both `.dark` (forced) and `.auto` (system preference).
#### 18.2 Add `color-scheme` classes
Add these right after the `@custom-variant` block:
```css
.light {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
.auto {
color-scheme: light dark;
}
```
#### 18.3 Merge `:root` and `.dark` blocks using `light-dark()`
Instead of separate `:root` (light) and `.dark` blocks, define every variable once using `light-dark(lightValue, darkValue)`. The browser picks the correct value based on the `color-scheme` property set by the classes above.
```css
/* Before (two blocks with duplicated variables) */
:root {
--background: oklch(1 0 0);
/* ... */
}
.dark {
--background: oklch(0.145 0 0);
/* ... */
}
/* After (single block, zero duplication) */
:root {
--background: light-dark(oklch(1 0 0), oklch(0.145 0 0));
--foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
/* ... */
}
```
Variables that share the same value in both modes don't need `light-dark()`. Delete the `.dark { ... }` block entirely.
### 19. Create src/lib/theme.ts
Server functions for cookie-based theme persistence, extracted into a utility module so the component doesn't import `@tanstack/react-start` directly (which has virtual module imports that break vitest browser mode pre-transforms):
```ts
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { z } from "zod";
const STORAGE_KEY = "app-theme";
export const THEME_VALUES = ["light", "dark", "auto"] as const;
export const themeSchema = z.enum(THEME_VALUES);
export const getTheme = createServerFn().handler(() => {
return themeSchema.parse(getCookie(STORAGE_KEY) ?? "auto");
});
export const setTheme = createServerFn()
.inputValidator(themeSchema)
.handler(({ data }) => setCookie(STORAGE_KEY, data));
```
### 19.1. Create src/components/theme-toggle.tsx
Toggle component that cycles through light → dark → system:
Import sun, moon, and monitor icons from the icon library configured in `components.json`.
```tsx
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { Monitor, Moon, Sun } from "<icon-library>"; // use the project's icon library
import { setTheme, THEME_VALUES, themeSchema } from "@/lib/theme";
import { Button } from "./ui/button";
export function ThemeToggle(props: React.ComponentProps<typeof Button>) {
const { theme } = useRouteContext({ from: "__root__" });
const router = useRouter();
function toggleTheme() {
const next =
THEME_VALUES[(THEME_VALUES.indexOf(theme) + 1) % THEME_VALUES.length];
setTheme({ data: themeSchema.parse(next) }).then(() =>
router.invalidate(),
);
}
const THEME_LABELS = {
light: "Light",
dark: "Dark",
auto: "System",
} as const;
let Icon = Monitor;
if (theme === "dark") {
Icon = Moon;
} else if (theme === "light") {
Icon = Sun;
}
return (
<Button
aria-label="Toggle theme"
onClick={toggleTheme}
size="sm"
variant="outline"
{...props}
>
<Icon />
{THEME_LABELS[theme]}
</Button>
);
}
```
### 19.2. Create src/components/theme-toggle.test.tsx
```tsx
import { page } from "vitest/browser";
import { render } from "vitest-browser-react";
import { expect, test, vi } from "vitest";
const { mockSetTheme, mockInvalidate } = vi.hoisted(() => ({
mockSetTheme: vi.fn(() => Promise.resolve()),
mockInvalidate: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({
useRouteContext: vi.fn(() => ({ theme: "auto" })),
useRouter: vi.fn(() => ({ invalidate: mockInvalidate })),
}));
vi.mock("@/lib/theme", () => ({
setTheme: mockSetTheme,
THEME_VALUES: ["light", "dark", "auto"] as const,
themeSchema: { parse: (v: string) => v },
}));
import { useRouteContext } from "@tanstack/react-router";
import { ThemeToggle } from "./theme-toggle";
test("displays System label for auto theme", async () => {
render(<ThemeToggle />);
await expect.element(page.getByRole("button", { name: "Toggle theme" })).toHaveTextContent("System");
});
test("displays Light label for light theme", async () => {
vi.mocked(useRouteContext).mockReturnValue({ theme: "light" });
render(<ThemeToggle />);
await expect.element(page.getByRole("button", { name: "Toggle theme" })).toHaveTextContent("Light");
});
test("displays Dark label for dark theme", async () => {
vi.mocked(useRouteContext).mockReturnValue({ theme: "dark" });
render(<ThemeToggle />);
await expect.element(page.getByRole("button", { name: "Toggle theme" })).toHaveTextContent("Dark");
});
test("calls setTheme on click", async () => {
vi.mocked(useRouteContext).mockReturnValue({ theme: "auto" });
render(<ThemeToggle />);
await page.getByRole("button", { name: "Toggle theme" }).click();
expect(mockSetTheme).toHaveBeenCalled();
});
```
### 20. Update src/routes/__root.tsx for theme support
Import `getTheme` and add `beforeLoad` to read the theme cookie on every navigation:
```tsx
import { getTheme } from "../lib/theme";
export const Route = createRootRoute({
beforeLoad: async () => ({ theme: await getTheme() }),
// ... rest of config
});
```
Apply the theme class to `<html>`:
```tsx
function RootDocument({ children }: { children: React.ReactNode }) {
const { theme } = Route.useRouteContext();
return (
<html className={theme} lang="en">
{/* ... */}
</html>
);
}
```
### 21. Add ThemeToggle to the index route
```tsx
import { ThemeToggle } from "../components/theme-toggle";
function RouteComponent() {
return (
<main className="grid min-h-svh place-items-center">
<ThemeToggle />
{/* ... */}
</main>
);
}
```
### 22. Update .github/workflows/ci.yml
Add an `e2e` job alongside the existing `check` job. It installs Playwright browsers, builds the app, and runs E2E tests against the production build:
```yaml
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Build
run: bun run build
- name: Run E2E tests
run: bun run e2e
```
### 23. Create e2e/app.spec.ts
A smoke test that verifies the app boots and the theme toggle works end-to-end. Uses `test` from `./base` so `page.goto()` waits for hydration automatically:
```ts
import { expect } from "@playwright/test";
import { test } from "./base";
test("homepage loads", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading")).toBeVisible();
});
test("theme toggle cycles through modes", async ({ page }) => {
await page.goto("/");
const toggle = page.getByRole("button", { name: "Toggle theme" });
// Default is auto (System)
await expect(toggle).toContainText("System");
await toggle.click();
await expect(toggle).toContainText("Light");
await toggle.click();
await expect(toggle).toContainText("Dark");
await toggle.click();
await expect(toggle).toContainText("System");
});
```Related Skills
solidstart-websocket
SolidStart WebSocket: experimental WebSocket endpoints, connection handling, message events, real-time communication, bidirectional data flow.
solidstart-api-routes
SolidStart API routes: export GET/POST/PATCH/DELETE functions, handle APIEvent with request/params/fetch, GraphQL and tRPC integration, session management.
setup-project
Setup Claude Code Configuration with full RALPH autonomous development integration
setup-node
Sets up Node.js/TypeScript development environment with npm/yarn, dependencies, ESLint, Prettier, testing (Jest/Vitest), and TypeScript type checking. Ensures consistent tooling configuration. Use when starting work on Node.js/TypeScript projects, after cloning repositories, setting up CI/CD, or troubleshooting environment issues.
setup-earl
Installs Earl, configures MCP integration for your agent platform, writes CLAUDE.md instructions, and routes to template creation or migration. Use when setting up Earl for the first time, when a new developer is onboarding to a project that uses Earl, or when Earl needs to be connected to an agent platform.
setup-claude-code
Claude Code 프로젝트 설정 생성
setup-cdk-templates
Use when creating CLAUDE.md files or .claude/ directories - detects project type, generates appropriate templates, and scaffolds Claude configuration with commands and hooks
setup-api-key
Guide users through obtaining and configuring a Vapi API key. Use when the user needs to set up Vapi, when API calls fail due to missing keys, or when the user mentions needing access to Vapi's voice AI platform.
session-start
Initialize a new mission with role selection and persona naming
session-start-routine
Execute work session initialization routine at conversation start. Identifies new skills, researches improvements for existing skills, and updates skill library. Triggers automatically at session start.
sentry-setup-tracing
Setup Sentry Tracing (Performance Monitoring) in any project. Use this when asked to add performance monitoring, enable tracing, track transactions/spans, or instrument application performance. Supports JavaScript, TypeScript, Python, Ruby, React, Next.js, and Node.js.
sentry-setup-metrics
Setup Sentry Metrics in any project. Use this when asked to add Sentry metrics, track custom metrics, setup counters/gauges/distributions, or instrument application performance metrics. Supports JavaScript, TypeScript, Python, React, Next.js, and Node.js.