Topic 27.6
Ship It
In one line
Milestone 6: protect the app with an error boundary and error reporting, split the archive into its own lazily loaded chunk, build it with Vite, deploy it with an SPA fallback, write a README, and plan what to add next.
Think of it like this
Opening a shop. The shelves are stocked and the till works, but before the doors open you put up a sign with the opening hours, check the fire exit, give a friend a key in case something breaks, and write down what to stock next month. Shipping is that last checklist: the app must start fast, fail gracefully, be findable, and be easy for the next person (often future you) to understand.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Ship
- Release the app so real users can use it.
- Error boundary
- A component that catches rendering errors below it and shows a fallback instead of a blank page.
- onCaughtError
- A
createRootoption (React 19) called for every error an error boundary catches, used for reporting. - Code splitting
- Breaking the bundle into files that load only when needed, such as one route's code.
- lazy
- React's function for a component whose code is loaded on first render with
import(). - Build
- Turning source files into small optimised files in
dist/that a browser can load quickly. - SPA fallback
- A host rule that answers any unknown path with
index.htmlso the client-side router can show the right page. - README
- The document at the top of a project that explains what it is, how to run it and how it works.
Step by step
01Add the safety net
Wrap the routes in an error boundary and pass error callbacks to createRoot. Test it by throwing on purpose (the first example loads a task with no title).
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App.jsx";
import { report } from "./monitoring.js";
import "./index.css";
createRoot(document.getElementById("root"), {
onCaughtError: (error, info) => report(error, info.componentStack),
onUncaughtError: (error, info) => report(error, info.componentStack),
}).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);02Lazy-load the archive route
The import moves inside lazy, and Suspense gives a fallback. Vite makes a separate chunk for it.
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router";
import BoardPage from "./routes/BoardPage.jsx";
const Archive = lazy(() => import("./routes/Archive.jsx"));
export default function App() {
return (
<ErrorBoundary>
<Suspense fallback={<p role="status">Loading…</p>}>
<Routes>
<Route path="/" element={<BoardPage />} />
<Route path="/archive" element={<Archive />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}03Build and preview
This is Vite 8's output for the starter template (Topic 0.4). Your board's list has one more dist/assets/*.js line for the archive chunk, and different sizes and hashes.
$ npm run build
> my-app@0.0.0 build
> vite build
vite v8.3.3 building client environment for production...
transforming...
✓ 20 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-DykytF2W.css 4.10 kB │ gzip: 1.47 kB
dist/assets/index-CyBHeG3D.js 222.52 kB │ gzip: 69.26 kB
✓ built in 314ms
$ npm run preview # serves dist/ locally: test the real build here04Deploy with an SPA fallback and cache headers
On Vercel, connect the repository (it detects Vite and publishes dist/) and add vercel.json. On Netlify, put _redirects and _headers in public/. Then open a deep link like /archive directly and reload it: it must load the app, not a 404.
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/assets/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}05Write the README
Write it for a stranger who has five minutes. Lead with what it does and the demo link; keep decisions short and honest.
06Plan the next version
Pick one item from "what to add next" and run it through RADIO again (27.1): new requirements, where new state lives, API changes, tests. The app grows the way it was built: one milestone at a time.
Try it yourself
- 1
Open the archive first
In the lazy route example, set
initialEntries={["/archive"]}onMemoryRouter. Predict: does the fallback appear now? Which console lines change? - 2
Reset properly
In the error boundary example, make Try again useful: pass
onResetto the boundary fromBoard's parent and remove bad tasks there (or give the boundary akeythat changes). Predict what happens on Try again before and after. - 3
Write your README
Fill the README outline for your own board: one paragraph per section. Ask someone who hasn't seen the code to run it from the README alone.
Code & diagrams
The script clicks **Load a bad task**. The card throws while rendering, the boundary shows its fallback, `onCaughtError` reports it, and the heading and footer stay. **Try again** would render the bad task and fail again: in a real app, refetch or drop the bad record first.
Preview
Console
Nothing logged yet.Expected console output
report to monitoring: Cannot read properties of undefined (reading 'toUpperCase')
on screen: The board hit a problem. Your tasks are saved.React Router wraps navigations in a transition, so while the chunk downloads the board stays on screen instead of flashing the fallback. The fallback appears only when the archive is the first screen: try `<MemoryRouter initialEntries={["/archive"]}>`. The second visit is instant: `lazy` caches the loaded module.
Preview
Console
Nothing logged yet.Expected console output
first screen: Board
while downloading: Board
archive chunk downloaded
then: Archive: 5,000 tasks
second visit, no download: Archive: 5,000 tasks# public/_redirects (copied into dist/ by Vite)
/* /index.html 200
# public/_headers
/assets/*
Cache-Control: public, max-age=31536000, immutable# Task board
A keyboard-accessible Kanban board built with React 19.
**Live demo:** https://your-app.vercel.app · 
## Features
- Add, edit, move (buttons or Alt + ←/→) and delete tasks
- Search and priority filters in the URL (shareable links)
- Saved in localStorage; optimistic moves with rollback on server errors
- 5,000-task archive with a virtualised list, loaded on demand
## Run it
npm install · npm run dev · npm test · npm run build
## How it works
- Folder map (src/state, src/api, src/components, src/routes)
- State plan: tasks in a reducer + external store, filters in the URL, UI details local
- Data flow diagram
## Decisions and trade-offs
- Why an external store over useReducer + useEffect
- Why optimistic moves undo only the failed change
## Accessibility
- Keyboard map, focus rules, live region messages
- Tested with: keyboard only, NVDA + Firefox, VoiceOver + Safari, axe
## What's next
- Drag and drop with keyboard alternative · real API · real-time syncBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Deploy without an SPA fallback
Deploy dist/ to a static host with no rewrite rule, open the app, click Archive, then reload the page.
Break #2
Remove the error boundary
In the first example, render <Board /> without <ErrorBoundary>.
Myth vs fact
Myth
If it works with npm run dev, the build will work too.
Fact
Development and production differ: minification, tree shaking, environment variables baked in at build time, real chunk loading and the host's routing. Always test npm run preview and the deployed URL, including a reload on a deep link.
Myth
Code splitting always makes an app faster.
Fact
Each chunk is another request. Split rarely used routes and heavy features; splitting everything into tiny pieces can make the main flow slower.
Myth
A README is optional for a personal project.
Fact
It is the first thing a reviewer, interviewer or future you reads. A good one shows you can explain decisions, not only write code.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Old chunks can disappear after a deploy while a user still has the old
index.htmlopen; their next lazy import fails with a chunk load error. Keep previous assets for a while and catch the error in a boundary that offers a reload (Topic 23.3). - ▸
Add a release version to error reports (
import.meta.envor a build-time define) so you can tell which deploy introduced a crash, and upload source maps to the error tracker rather than serving them publicly if your code is private. - ▸
Gate deploys on
npm test, lint (oxlint in new Vite projects) and a Playwright smoke test of the main flow with the keyboard. Preview deployments per pull request let reviewers try the change before it merges. - ▸
Check Core Web Vitals on the deployed site (Topic 23.4): the board's LCP is the heading and first cards, INP is dominated by moves and typing in Search, and CLS comes from the loading message pushing content; reserve space for it.
Remember this
- 1
Fail gracefully (Topic 14.5 and 23.5). A rendering error with no error boundary unmounts the whole app: a blank page. Wrap the board in a boundary that shows "The board hit a problem" with a Try again button, so the header and the rest keep working. Report the error:
createRoot(container, { onCaughtError, onUncaughtError })(React 19) receives every error with its component stack, which you send to a monitoring service along with the release version. Upload source maps to that service so the stack points at your source, not minified code. - 2
Split what most users don't need (Topic 16.8). Most visits never open the archive, so its code (the route and the virtual list) shouldn't be in the first download.
const Archive = lazy(() => import("./routes/Archive.jsx"))makes Vite put it in a separate file that downloads on first visit;<Suspense fallback>shows a placeholder while it loads when the archive is the first screen. React Router wraps navigations in a transition, so clicking the Archive link keeps the board on screen until the chunk arrives (the live example shows it). - 3
Build (Topic 23.1).
npm run buildrunsvite build: it bundles and minifies the code, removes unused exports (tree shaking), turnsimport()calls into separate chunks, and writesdist/withindex.htmland content-hashed files indist/assets/. The development warnings and StrictMode double calls are gone.npm run previewservesdist/locally so you can test the real build, including keyboard flows, before deploying. - 4
Deploy (Topic 23.3).
dist/is static files: any static host works. Two settings matter. The SPA fallback: a request for/archiveor/?q=bugmust returnindex.htmlso the router can handle it (Vercelrewrites, Netlify_redirectswith200, nginxtry_files). And caching: hashed files in/assets/can be cached for a year (immutable), whileindex.htmlmust be revalidated (no-cache) so users get each new release. Vercel and Netlify build on every push and give each pull request a preview URL. - 5
The README is the app's front door: what it does (with a screenshot), how to run it (
npm install,npm run dev,npm test), how it is built (folder map, state plan, where data lives), the decisions and trade-offs (why a store, why the URL, why optimistic), accessibility notes (keyboard shortcuts, what was tested with which screen reader), and the live demo link. Recruiters and interviewers read it before the code. - 6
What to add next, each a lesson in itself: drag and drop with keyboard alternatives (dnd-kit); reordering within a column; a real backend (a small API with a database) and TanStack Query for server state (Topic 14.6); sign-in (Topic 21.x for safe token handling); offline support with a service worker and a queue of pending moves; real-time updates between users over WebSockets (Topic 25.5); undo/redo (keep past actions, Topic 25.7); TypeScript across the codebase (Phase 12); a server-rendered version with Next.js (Phase 20); end-to-end tests with Playwright and axe (Topic 17.7).
Explain it without notes
What would you check before shipping a React app?
Why does a client-side routed app need an SPA fallback, and how do you configure one?
How do error boundaries and the root error callbacks work together?
Which parts of the task board would you code-split, and why?
Practice
Make the error boundary's Try again button reset the board to the saved tasks.
Write the nginx location blocks for the task board: hashed assets cached for a year, everything else falling back to index.html.
Trade-offs
- ↔
Static hosting (Vercel, Netlify, a CDN) is cheap and simple for a client-rendered app; server rendering (Phase 20) improves first load and SEO but needs a server or a framework host.
- ↔
More error boundaries mean smaller blast radius but more fallback UI to design; start with one per route and add more around risky widgets.
Done when you can
I can add error boundaries and report errors through
createRootcallbacks.I can lazy-load a route and explain what the user sees while it loads.
I can build, preview and deploy a Vite app with an SPA fallback and cache headers.
I can write a README that explains what, how to run, how it works and why.
I can plan the next feature with the same milestone approach.