Awesome-omni-skill 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.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/setup-tanstack-start" ~/.claude/skills/diegosouzapw-awesome-omni-skill-setup-tanstack-start && rm -rf "$T"
manifest: skills/development/setup-tanstack-start/SKILL.md
source content

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

{
  "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

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

bunx playwright install chromium

3. Update tsconfig.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

{
  "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

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

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:

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
:

import { expect } from "@playwright/test";
import { test } from "./base";

10. Update src/lib/env.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:

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:

    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/...
    )
    DestinationAction
    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:

    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
.

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

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

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

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

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

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

/* 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:

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

/* 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):

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
.

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

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:

import { getTheme } from "../lib/theme";

export const Route = createRootRoute({
  beforeLoad: async () => ({ theme: await getTheme() }),
  // ... rest of config
});

Apply the theme class to

<html>
:

function RootDocument({ children }: { children: React.ReactNode }) {
  const { theme } = Route.useRouteContext();

  return (
    <html className={theme} lang="en">
      {/* ... */}
    </html>
  );
}

21. Add ThemeToggle to the index route

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:

  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:

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");
});