Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ updates:
patterns: ["oxlint", "oxlint-*", "oxfmt"]
react:
patterns: ["react", "react-dom", "@types/react", "@types/react-dom"]
# oxc-transform-react must stay in the range @vitejs/plugin-react expects.
vite-and-vitest:
patterns:
["vite", "@vitejs/*", "oxc-transform-react", "vitest", "@vitest/*"]
Expand All @@ -24,6 +23,10 @@ updates:
# Matches the Node major in .node-version; bump both together.
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# @vitejs/plugin-react pins a 0.x range (e.g. ^0.152.0 allows only 0.152.x).
# Bump it by hand when the plugin's peer range moves.
- dependency-name: "oxc-transform-react"
update-types: ["version-update:semver-minor"]

- package-ecosystem: github-actions
directory: /
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
- run: pnpm format:check
- run: pnpm lint --deny-warnings
- run: pnpm typecheck
- run: pnpm lint:unused
- run: pnpm test

build:
Expand All @@ -34,3 +35,18 @@ jobs:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup
- run: pnpm ${{ matrix.target == 'spa' && 'build:spa' || 'build' }}

e2e:
name: Browser tests (SSR and SPA)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup
- run: pnpm exec playwright install --with-deps --only-shell chromium
- run: pnpm test:e2e
- if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces
path: test-results/
retention-days: 7
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ coverage
.idea
.DS_Store
*.sw?

# Playwright
test-results
playwright-report
blob-report
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ Package manager: pnpm. Node 24.
## Commands

- `pnpm dev`: dev server with SSR on http://localhost:3000 (`pnpm dev:spa` for SPA mode)
- `pnpm check`: format check + lint (type-aware, warnings fail) + typecheck. Run before finishing.
- `pnpm check`: format check + lint (type-aware, warnings fail) + typecheck + Knip (unused
files, exports and dependencies). Run before finishing.
- `pnpm test`: Vitest. `pnpm test:watch` for watch mode.
- `pnpm test:e2e`: builds both targets and runs the Playwright browser tests in `e2e/`.
- `pnpm lint:fix` / `pnpm format`: auto-fix lint issues / format everything
- `pnpm lint:react`: React Doctor scan (React anti-patterns, a11y, security)
- `pnpm build` / `pnpm build:spa`: SSR build (`.output/`) / static SPA build (`dist/client/`)
Expand All @@ -21,8 +23,9 @@ Package manager: pnpm. Node 24.
the route `loader`, and read it with `useSuspenseQuery(options)` in the component.
`ensureQueryData`/`fetchQuery`/`prefetchQuery` are deprecated.
- The React Compiler is on: don't add `useMemo`, `useCallback` or `memo` for performance.
- Code must work in both modes. Server functions and server routes only work in the SSR
build; in the SPA build everything runs in the browser.
- Code must work in both modes. Server functions (`createServerFn`, kept in `src/server/`)
and server routes only work in the SSR build; check `isSpa` from `#/lib/env` and render a
fallback in the SPA build, like `src/routes/server.tsx` does.

## UI

Expand Down
59 changes: 35 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
| Language | [TypeScript 7](https://www.typescriptlang.org/) (native compiler) |
| UI | [Tailwind CSS 4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/) on [Base UI](https://base-ui.com/), [Lucide](https://lucide.dev/) icons |
| Lint & format | [Oxlint](https://oxc.rs/docs/guide/usage/linter) (type-aware) + [Oxfmt](https://oxc.rs/docs/guide/usage/formatter), [@shadcn/lint](https://github.com/shadcn-ui/lint), [React Doctor](https://react.doctor/) |
| Tests | [Vitest 5](https://vitest.dev/), [Testing Library](https://testing-library.com/), jsdom |
| Tooling | [pnpm](https://pnpm.io/), Husky + lint-staged, GitHub Actions, Dependabot |
| Tests | [Vitest 5](https://vitest.dev/) + [Testing Library](https://testing-library.com/), [Playwright](https://playwright.dev/) browser tests for both builds |
| Tooling | [pnpm](https://pnpm.io/), [Knip](https://knip.dev/), Husky + lint-staged, GitHub Actions, Dependabot |

## ⚠️ Disclaimer

Expand Down Expand Up @@ -45,57 +45,63 @@ pnpm dev # http://localhost:3000

TanStack Start renders on the server by default. Run the same app as a pure client-side SPA with the `spa` mode:

| | SSR server (default) | Static SPA |
| ---------------- | --------------------------------------------------------------- | -------------------------------------------- |
| Dev | `pnpm dev` | `pnpm dev:spa` |
| Build | `pnpm build` → `.output/` | `pnpm build:spa` → `dist/client/` |
| Run / deploy | `pnpm start`, or any [Nitro preset](https://nitro.build/deploy) | Any static host (GitHub Pages, S3, Netlify…) |
| Route loaders | Server on first load, then client | Always in the browser |
| Server functions | ✅ | ❌ (no server) |
| | SSR server (default) | Static SPA |
| --------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------- |
| Dev | `pnpm dev` | `pnpm dev:spa` |
| Build | `pnpm build` → `.output/` | `pnpm build:spa` → `dist/client/` |
| Run / deploy | `pnpm start`, or any [Nitro preset](https://nitro.build/deploy) | Any static host (GitHub Pages, S3, Netlify…) |
| Route loaders | Server on first load, then client | Always in the browser |
| [Server functions](src/server/server-info.ts) | ✅ | ❌ (no server) |

- **Deploy the server** anywhere Nitro supports: `NITRO_PRESET=vercel pnpm build` (or `netlify`, `cloudflare`, `bun`, …). The default `node` preset runs with `pnpm start`.
- **Deploy the SPA** under a sub-path by setting `BASE_PATH`, e.g. `BASE_PATH=/my-repo/ pnpm build:spa`. The build writes `index.html` plus a `404.html` copy so static hosts fall back to the app for client-side routes.
- The included [`deploy-pages.yml`](.github/workflows/deploy-pages.yml) workflow publishes the SPA to GitHub Pages on every push to `main`. Enable it under **Settings → Pages → Source: GitHub Actions**.

## 📜 Scripts

| Command | Description |
| ----------------- | ------------------------------------------------------ |
| `pnpm dev` | Start the dev server (SSR) |
| `pnpm dev:spa` | Start the dev server in SPA mode |
| `pnpm build` | Build the SSR server with Nitro |
| `pnpm build:spa` | Build the static SPA |
| `pnpm start` | Run the SSR build |
| `pnpm preview` | Preview the SSR build (`pnpm preview:spa` for the SPA) |
| `pnpm test` | Run tests once (`pnpm test:watch` to watch) |
| `pnpm check` | Format check + lint + typecheck, the same as CI |
| `pnpm lint` | Lint with Oxlint (`pnpm lint:fix` to auto-fix) |
| `pnpm lint:react` | Scan for React issues with React Doctor |
| `pnpm format` | Format with Oxfmt (`pnpm format:check` to only check) |
| `pnpm typecheck` | Type-check with TypeScript 7 |
| Command | Description |
| ------------------ | ------------------------------------------------------- |
| `pnpm dev` | Start the dev server (SSR) |
| `pnpm dev:spa` | Start the dev server in SPA mode |
| `pnpm build` | Build the SSR server with Nitro |
| `pnpm build:spa` | Build the static SPA |
| `pnpm start` | Run the SSR build |
| `pnpm preview` | Preview the SSR build (`pnpm preview:spa` for the SPA) |
| `pnpm test` | Run tests once (`pnpm test:watch` to watch) |
| `pnpm test:e2e` | Build both targets and run the Playwright browser tests |
| `pnpm check` | Format check, lint, typecheck and unused-code check |
| `pnpm lint` | Lint with Oxlint (`pnpm lint:fix` to auto-fix) |
| `pnpm lint:react` | Scan for React issues with React Doctor |
| `pnpm lint:unused` | Find unused files, exports and dependencies with Knip |
| `pnpm format` | Format with Oxfmt (`pnpm format:check` to only check) |
| `pnpm typecheck` | Type-check with TypeScript 7 |

## 🗂️ Project structure

```
.
├── .github/ # CI, GitHub Pages deploy, React Doctor, Dependabot
├── e2e/ # Playwright browser tests for the SSR and SPA builds
├── public/ # Static files, served from BASE_PATH
├── scripts/setup.ts # Turns a clone into your own project
├── src/
│ ├── components/
│ │ └── ui/ # shadcn/ui components (yours to edit)
│ ├── lib/utils.ts # cn() class merging
│ ├── lib/ # cn() class merging, isSpa flag
│ ├── queries/ # TanStack Query options
│ ├── routes/ # File-based routes (__root.tsx is the HTML shell)
│ ├── server/ # Server functions (SSR build only)
│ ├── test/ # Test setup and helpers
│ ├── router.tsx # Router + QueryClient setup
│ ├── routeTree.gen.ts # Generated by TanStack Router, don't edit
│ └── styles.css # Tailwind + shadcn theme tokens
├── AGENTS.md # Instructions for coding agents
├── components.json # shadcn/ui config
├── doctor.config.ts # React Doctor config
├── knip.json # Unused-code checker config
├── oxfmt.config.ts # Formatter config
├── oxlint.config.ts # Linter config
├── playwright.config.ts # Browser test config
├── vite.config.ts # Vite + Start + Nitro (SSR and SPA modes)
└── vitest.config.ts # Test config
```
Expand Down Expand Up @@ -132,6 +138,7 @@ Everything runs on the [Oxc](https://oxc.rs/) toolchain, which is much faster th
- **TanStack Query and Router** ESLint plugins run inside Oxlint as JS plugins.
- **Oxfmt** formats code with Prettier-compatible output, and also sorts imports, Tailwind classes and `package.json`.
- **React Doctor** checks for React anti-patterns, accessibility and security issues (`pnpm lint:react`), and comments on every PR through its GitHub Action.
- **Knip** finds unused files, exports and dependencies (`pnpm lint:unused`, part of `pnpm check`), the kind of leftovers refactors and coding agents leave behind.

A pre-commit hook (Husky + lint-staged) fixes and formats staged files. In VS Code, install the recommended extensions (Oxc, TypeScript 7 and Tailwind CSS IntelliSense) to get format-on-save and inline lint errors.

Expand All @@ -157,6 +164,10 @@ it("increments", async () => {
});
```

### Browser tests

`pnpm test:e2e` builds both targets and runs [Playwright](https://playwright.dev/) against each: the SSR server, and the SPA hosted under a sub-path the way GitHub Pages serves it. Every test also fails on console errors, so hydration mismatches and broken deep links get caught. Install the browser once with `pnpm exec playwright install chromium`.

## 🤖 Working with coding agents

- [`AGENTS.md`](AGENTS.md) explains the commands and conventions to agents (`CLAUDE.md` imports it).
Expand Down
110 changes: 110 additions & 0 deletions e2e/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Builds both targets and serves them for the browser tests (started by playwright.config.ts):
// SSR: the Nitro server from .output/
// SPA: dist/client/ under a sub-path, hosted like GitHub Pages (files first,
// then directory index.html, then 404.html with a 404 status)
// Pass --no-build to serve the existing build output.

import { execFileSync, spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { extname, join, resolve } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

const ssrPort = process.env.SSR_PORT ?? "4173";
const spaPort = process.env.SPA_PORT ?? "4174";
const basePath = process.env.SPA_BASE_PATH ?? "/react-ts-template/";

const vite = resolve("node_modules/vite/bin/vite.js");

if (!process.argv.includes("--no-build")) {
const { BASE_PATH: _, ...env } = process.env;
execFileSync(process.execPath, [vite, "build"], { stdio: "inherit", env });
execFileSync(process.execPath, [vite, "build", "--mode", "spa"], {
stdio: "inherit",
env: { ...env, BASE_PATH: basePath },
});
}

const ssr = spawn(process.execPath, [".output/server/index.mjs"], {
stdio: "inherit",
env: { ...process.env, PORT: ssrPort },
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
ssr.kill();
process.exit();
});
}

async function waitForServer(url: string, attemptsLeft = 100): Promise<void> {
try {
await fetch(url);
} catch (error) {
if (attemptsLeft === 0) throw error;
await sleep(100);
return waitForServer(url, attemptsLeft - 1);
}
}

// Playwright waits for the SPA URL, so only start that server once SSR is up.
await waitForServer(`http://localhost:${ssrPort}/`);

const contentTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".svg": "image/svg+xml",
".woff2": "font/woff2",
};

async function isFile(path: string) {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}

const root = resolve("dist/client");

async function serveSpa(request: IncomingMessage, response: ServerResponse) {
const { pathname } = new URL(request.url ?? "/", "http://localhost");
if (!pathname.startsWith(basePath)) {
response.writeHead(404).end();
return;
}

let file = join(root, decodeURIComponent(pathname.slice(basePath.length)));
if (!file.startsWith(root)) {
response.writeHead(400).end();
return;
}

let status = 200;
if (!(await isFile(file))) {
if (await isFile(join(file, "index.html"))) {
file = join(file, "index.html");
} else {
status = 404;
file = join(root, "404.html");
}
}

response.writeHead(status, {
"content-type": contentTypes[extname(file)] ?? "application/octet-stream",
});
response.end(await readFile(file));
}

createServer((request, response) => {
serveSpa(request, response).catch((error: unknown) => {
console.error(error);
response.writeHead(500).end();
});
}).listen(Number(spaPort), () => {
console.info(`SPA: http://localhost:${spaPort}${basePath}`);
});
Loading
Loading