Owns the static-build contract that emits apps/<app>/dist/client/ for upload to GitHub Pages. There is no apps/<app>/nitro.config.ts file in dean-stack — Nitro v3 is driven internally by TanStack Start's Vite plugin via tanstackStart({ spa: …, prerender: … }). This skill owns the GH-Pages-specific concerns layered on top.
When to invoke
- Touching
.github/workflows/deploy.yml,apps/<app>/package.json'sbuildscript, orapps/<app>/vite.config.ts'sbaseresolution. - Adding or fixing the
BASE_PATHenv contract for project pages / user-org pages / custom domains. - Diagnosing a missing
404.html, missing.nojekyll, or 404'd asset paths in the deployed site. - Deciding which app in the monorepo gets published.
Owns
- The
BASE_PATHenv contract — the single channel that drives Vite'sbaseand therefore every emitted asset URL. Set canonically byactions/configure-pages@v5'sbase_pathoutput in CI. - The post-Vite-build steps that turn
dist/client/into a GH-Pages-compliant payload:cp dist/client/index.html dist/client/404.htmlfor the SPA fallback,touch dist/client/.nojekyllfor the Jekyll opt-out marker. - The deploy workflow's
APPselector —inputs.app(workflow_dispatch) →vars.PAGES_APP(repo var fallback) →web(default), feeding both the Turbo filter and the upload path. - The
prerender.failOnError: trueinvariant — a missing prerender route fails CI (Pillar 4).
Defers to
tanstack-start-spa-prerender(Wave 3, forward) — for the application-level SPA + prerender switch (tanstackStart({ spa: { enabled: true, prerender: { outputPath: "/index" } }, prerender: { enabled: true, crawlLinks: true } })).tanstack-router-pwa-deep-links(Wave 3, forward) — for the Workbox navigation-fallback contract that resolves deep links offline against the prerendered shell.vite— forvite.config.ts'sresolveBase()helper that consumesBASE_PATH.turborepo— forturbo run build --filter=@dean-stack/<app>task ordering and the build cache.bun-runtime— for invokingbun run buildandbun install --frozen-lockfilein CI.
Dean-stack rules
- There is no
apps/<app>/nitro.config.ts. TanStack Start's Vite plugin owns Nitro configuration. Don't introduce a standalone Nitro config — it duplicates and races with the framework-level config. - The static artifact lives at
apps/<app>/dist/client/, NOT.output/public/. Vite writes there directly via TanStack Start's SPA + prerender pipeline. Theapps/<app>/package.jsonbuildscript isvite build && cp dist/client/index.html dist/client/404.html && touch dist/client/.nojekyll— that's the entire post-build contract. - Pillar 4 (CLI-gate-first): the build is part of
bun run buildand any prerender error MUST fail the gate (tanstackStart({ prerender: { failOnError: true } })). - The deploy workflow MUST drive
BASE_PATHfromactions/configure-pages@v5'sbase_pathoutput — never hardcode/dean-stack/or any repo name. The output is/<repo>for project pages and/(or empty) for user-org pages and custom domains.vite.config.ts'sresolveBase()normalizes the trailing slash. - The package is
nitro(v3), notnitropack(v2). Imports come fromnitro/nitro/vite/nitro/storageif you ever need them — but you generally won't, because TanStack Start mediates. - The deploy workflow publishes ONE app per run. The selector is
inputs.app(workflow_dispatch) →vars.PAGES_APP→'web'. To publish a different app, either trigger workflow_dispatch withapp: <name>or set thePAGES_APPrepo variable.
Patterns
apps/<app>/vite.config.ts — base-path resolver
function resolveBase(): string {
const raw = process.env.BASE_PATH;
if (!raw || raw === "/") return "/";
return raw.endsWith("/") ? raw : `${raw}/`;
}
export default defineConfig(async ({ mode }) => {
Object.assign(process.env, loadEnv(mode, process.cwd(), ""));
await import("./app/env");
return {
base: resolveBase(),
plugins: [
tanstackStart({
srcDirectory: "app",
spa: { enabled: true, prerender: { outputPath: "/index" } },
prerender: {
enabled: true,
crawlLinks: true,
autoSubfolderIndex: true,
failOnError: true,
},
}),
// ... VitePWA, Tailwind, etc.
],
};
});
Local dev: BASE_PATH unset → /. CI: BASE_PATH=/dean-stack (project pages) → /dean-stack/. Custom domain: BASE_PATH=/ (or unset) → /.
apps/<app>/package.json — build script
"build": "vite build && cp dist/client/index.html dist/client/404.html && touch dist/client/.nojekyll"
The cp provides the SPA fallback GH Pages serves on any 404. The touch .nojekyll opts out of Jekyll's underscore-folder stripping (Vite emits _metadata.json and similar). Both are load-bearing.
.github/workflows/deploy.yml — selector + base path + upload
on:
push: { branches: [main] }
workflow_dispatch:
inputs:
app:
description: "App to publish (folder name under apps/)"
required: false
default: web
type: string
env:
APP: ${{ github.event.inputs.app || vars.PAGES_APP || 'web' }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: ".nvmrc" }
- uses: oven-sh/setup-bun@v2
with: { bun-version-file: "package.json" }
- id: pages
uses: actions/configure-pages@v5
- run: bun install --frozen-lockfile
- run: bunx playwright install --with-deps chromium
# Pillar 4 — gate runs BEFORE the prod build, so we never deploy
# code that fails lint/types/unit/e2e. This is the only CI surface
# that runs on push to main; check.yml is PR-only.
- run: bun run check
- name: Build ${{ env.APP }}
env:
BASE_PATH: ${{ steps.pages.outputs.base_path }}
run: bun run build --filter=@dean-stack/${{ env.APP }}
- name: Verify SSG output
run: |
set -euo pipefail
dir="apps/${APP}/dist/client"
test -f "$dir/index.html" && test -f "$dir/404.html" && test -f "$dir/.nojekyll"
- uses: actions/upload-pages-artifact@v3
with:
path: apps/${{ env.APP }}/dist/client
The actions/configure-pages@v5 step runs before the build so BASE_PATH is in scope. Its base_path output is GitHub's canonical answer — handles project pages, user-org pages, and custom domains without local logic. Use upload-pages-artifact@v3 and deploy-pages@v4 — older revs no longer pass the deploy gate.
Add a route to the prerender list
Crawled links from the home page are picked up automatically (crawlLinks: true). For routes the crawler can't reach (islands, dynamic links), pass them explicitly via TanStack Start's prerender.routes:
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
failOnError: true,
routes: ["/games/maze", "/games/maze/level-1"],
},
});
Custom domain (no project base path)
Set the custom domain in repo settings → Pages. actions/configure-pages@v5 will then return an empty base_path, BASE_PATH is unset/empty in the build env, and resolveBase() returns /. No code change required.
Anti-patterns
- Don't create
apps/<app>/nitro.config.ts— TanStack Start drives Nitro; a standalone config duplicates and races with framework-level config. - Don't reference
.output/public— that's the upstream Nitro doc default, but TanStack Start's SPA mode writes todist/client/instead. The deploy workflow uploads fromapps/<app>/dist/client/, not.output/public/. - Don't import from
nitropack— that's v2; v3 isnitro/nitro/vite/nitro/storage. - Don't hardcode
/dean-stack/(or any repo name) invite.config.ts'sbase. The base must come fromBASE_PATHso the workflow is portable across forks, renames, custom domains, and user-org pages. - Don't omit the
cp index.html → 404.htmlstep — without it, GH Pages serves its own ugly 404 on deep links instead of the SPA shell. - Don't omit
touch .nojekyll— without it, Jekyll on Pages strips folders that start with_(Vite emits these) and the deploy silently breaks. - Don't introduce
serverHandlers,routeRules.cache,routeRules.swr,routeRules.proxy, oruseStorage— there is no server on GH Pages; these silently no-op or break the build. - Don't set
prerender.failOnError: falseto make a build pass — the missing route is the bug, fix the route. - Don't trust upstream Nitro doc YAML verbatim — its
actions/upload-pages-artifact@v1andactions/deploy-pages@v1revs are stale and fail the deploy gate. Use@v3/@v4.
Triggers on
nitro, github_pages, BASE_PATH, baseURL, prerender, SPA fallback 404, .nojekyll, GH Pages deploy, dist/client, configure-pages, deploy-pages, upload-pages-artifact