This template has been configured with all of the tools required to create a Multi Page React Application using TailwindCSS with Vite.
Each "page"/route is a fully independent HTML entry point with its own React root — there
is no client-side router. Navigation between pages is plain <a href> links that trigger
full page loads.
- React 19
- TailwindCSS v4 for utility CSS classes (via
@tailwindcss/postcss) - ESLint 9 (flat config) configured with some initial rules
- Prettier 3 to enforce consistent code style (auto-sorts Tailwind classes)
- Vite 8 to build the project for development or production
- Vitest 4 with Testing Library for the test suite
- Build-time prerendering: every page is rendered to static HTML and hydrated in the browser, so the output is SEO-friendly while staying entirely static
git clone https://github.com/CharlesInteractive/vite-react-tailwind-prettier-mpa-template.git- Use the Node version pinned in
.nvmrc(v22), e.g.nvm use - Run
npm installto install all of the project's dependencies - Run the local development server:
npm run dev - Build the project for production:
npm run build
Day to day:
| Command | What it does |
|---|---|
npm run dev |
Dev server with HMR. Client-only — pages are not prerendered here, so #root starts empty and <title> comes from the static index.html. |
npm run test:watch |
Re-run the affected tests as you edit |
npm test |
Run the whole suite once |
npm run lint |
ESLint at --max-warnings 0 — a warning fails the run |
npm run format |
Rewrite files with Prettier (also sorts Tailwind classes) |
Shipping:
| Command | What it does |
|---|---|
npm run build |
Full production build to dist/ — client, then SSR, then prerender |
npm run preview |
Serve dist/ locally: prerendered HTML, real asset hashes, exactly what deploys |
The three build passes are also runnable on their own, which is what you want when debugging a build failure:
SITE_URL=https://example.com npm run build adds absolute og:url / og:image tags.
CI does not check formatting — only
lint,testandbuild. Runnpm run formatbefore committing, or wireprettier --check .into your own pre-commit hook.
Tests run on Vitest with
Testing Library in a jsdom environment. Run them with
npm test (or npm run test:watch). What's covered:
src/components/Header.test.jsx- the active-link logic (the current path drives which nav link is highlighted, since there is no router).src/reloadOnChunkError.test.js- the stale-deploy safety net reloads once and is guarded against reload loops.tests/routes.test.js- the "add a page = update three places" invariant: everyvite.config.jsentry has its folder,navLinksstays in sync with the routes, and every page carries what the prerender needs (the HTML markers and ametaexport whose title matches its<title>).tests/hydration.test.jsx- every page's server-rendered markup hydrates in the browser without React logging a mismatch.scripts/prerenderHtml.test.js- the prerender's HTML transforms, including escaping of injected metadata.
CI (.github/workflows/ci.yml) runs lint, test and build on every push and pull
request using the Node version from .nvmrc, then asserts each built page actually got
prerendered — a page that silently shipped an empty #root would otherwise pass every
other check.
src/
├── index.html # root page (/) — carries the prerender markers
├── main.jsx # its client entry (hydrates the prerendered markup)
├── App.jsx # its component, plus the page's `meta` export
├── routea/ # example page: index.html + main.jsx + App.jsx
├── routeb/ # self-contained — copy, edit, or delete them
├── routec/
├── components/
│ ├── Header.jsx # shared nav + logo bar
│ ├── navLinks.js # the nav link list (one of the "three places")
│ └── pathname.js # usePathname() — works in the browser and the prerender
├── entry-server.jsx # SSR render entry; build-time only, never shipped
├── reloadOnChunkError.js # stale-deploy reload guard; client-only
├── index.css # the single stylesheet, imported by every page
├── assets/ # imported assets (content-hashed at build)
└── public/ # copied verbatim into dist/ (favicons, fonts)
scripts/
├── prerender.js # build pass 3 — renders pages into dist/
└── prerenderHtml.js # its pure HTML transforms (unit tested)
tests/
├── routes.test.js # the MPA invariants
└── hydration.test.jsx # server markup hydrates without a mismatch
vite.config.js # client build — the page registry lives here
vite.ssr.config.js # SSR build — derives its entries from vite.config.js
vitest.config.js # tests run from the repo root, unlike the build
tailwind.config.js # theme, loaded via @config in src/index.css
dist/ is build output: it is git-ignored and wiped on every build, so never edit it
by hand — React will overwrite anything you change inside #root when it hydrates.
Source lives in src/ (Vite's root is set to src/). Example pages routea, routeb,
and routec are self-contained and meant to be copied, edited, or deleted. Each page is a
folder with its own index.html + main.jsx + App.jsx.
To add your own page, update three places:
- Create
src/<name>/withindex.html,main.jsx,App.jsx(copy an existing route). - Register the entry in
vite.config.jsunderbuild.rollupOptions.input. The SSR build reads the same map, so there is nothing extra to register for prerendering. - Add a nav link to the
navLinksarray insrc/components/navLinks.js.
build: {
outDir,
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(root, "index.html"),
routea: resolve(root, "routea", "index.html"),
routeb: resolve(root, "routeb", "index.html"),
routec: resolve(root, "routec", "index.html"),
},
},
},Every page is rendered to real HTML during npm run build and hydrated in the browser,
so search engines, link-preview bots and users get content immediately instead of an
empty <div id="root">. The output is still entirely static — deploy it to Netlify,
Vercel, S3, GitHub Pages or any static host, with no server to run.
npm run build is three passes:
npm run build:client— the normal Vite build.npm run build:ssr— a Node-loadable build of every page'sApp.jsx.npm run prerender— renders each page and writes the HTML intodist/.
Each page's App.jsx exports its own metadata, which the prerender injects into that
page's <head>:
export const meta = {
title: "Route A · Vite + React + Tailwind CSS",
description:
"Route A — an example page in the Vite + React + Tailwind CSS multi-page template.",
};That produces <title>, <meta name="description"> and Open Graph / Twitter card
tags. Set SITE_URL=https://example.com at build time to also get absolute og:url
and og:image.
Two things to know when writing pages:
- Anything reachable from
App.jsxalso runs in Node, so don't touchwindow,documentorlocalStorageat module scope or during render — useuseEffect, or put the code inmain.jsx. For the current path, useusePathname()fromsrc/components/pathname.js. If a page can't be rendered, the build fails rather than silently shipping a blank one. - The dev server doesn't prerender, so
npm run devbehaves exactly as before (fast HMR, client-only). The<title>in eachindex.htmlis what dev shows; a test keeps it in sync withmeta.title.
The default project is styled with preconfigured Tailwind directives and layers. Learn more about Tailwind CSS here.
A font pack is also included (Nunito Sans) along with its Open Font License.
Assets are emitted with content-hashed filenames, so old chunks disappear on each deploy.
To avoid the "white screen after deploy" problem (a stale, cached index.html requesting
chunk hashes that no longer exist):
- Serve the HTML entries with
Cache-Control: no-cache(hashed assets underdist/assets/can be cached long-term). Configure this at your host/CDN. src/reloadOnChunkError.js(imported first by every entry) is a client-side safety net that does a one-time reload if a stale chunk fails to load after a deploy.
Prerendered HTML makes fresh-HTML serving more important, not less: the markup in each
index.html is tied to the asset hashes referenced alongside it.
See CLAUDE.md for the full details.
Feel free to open an issue or create a PR if you'd like to contribute.
The project is available as open source under the terms of the MIT License.

