The simplest possible full-stack SSR + SPA app on Val Town, built with React Router 7, Hono, and SQLite. A todo list in ~150 lines of app code that demonstrates every core pattern you need.
Want the full version? See the message board example with nested routes, search, multiple tables, and more.
hydrateRootroutes.ts on server and client;
serverLoader/serverAction keeps DB code off the clientdataStrategy — One request per navigation via
X-Data-Request header<Form method="post"> with intent
patternuseNavigation() for optimistic UI├── backend/
│ ├── index.ts # Hono HTTP entry — SSR + data API + static files
│ └── database.ts # Schema + all queries (one file, one table)
├── frontend/
│ ├── index.html # HTML shell template
│ └── index.tsx # Client hydration + dataStrategy
├── routes/
│ ├── Home.tsx # The entire UI — todo list + add form
│ ├── Home.loader.ts # Loads all todos from SQLite
│ └── Home.action.ts # Handles "add" and "toggle" form submissions
├── shared/
│ └── routes.ts # Route tree (shared between server + client)
└── README.md
First visit (SSR):
Browser → GET / → Hono → createStaticHandler → runs Home.loader
→ renderToString → full HTML response → Browser hydrates
After hydration (SPA):
User submits form → dataStrategy → POST / (X-Data-Request: true)
→ Hono → runs Home.action + Home.loader → JSON response
→ React Router updates UI
The route tree in shared/routes.ts is imported by both server and client. The
helpers make this work:
dataStrategy handles fetching)This means loader/action code and database dependencies never ship to the browser.
Instead of React Router calling each loader individually on the client, a custom
dataStrategy in frontend/index.tsx makes one HTTP request with an
X-Data-Request: true header. The server runs all matched loaders/actions and
returns their data as keyed JSON.
| Want to add... | What to do |
|---|---|
| More pages | Add routes to shared/routes.ts with new Component, loader, action |
| Nested layouts | Use children array in routes + <Outlet /> in parent component |
| More tables | Add queries to backend/database.ts (or split into separate files) |
| Search | Add a route with a loader that reads new URL(request.url).searchParams |
| Redirects from actions | Return redirect("/path") — the dataStrategy handles it automatically |
| Layer | Technology |
|---|---|
| Server | Hono |
| Routing | React Router 7 |
| UI | React 18 |
| Styling | Twind (Tailwind via CDN) |
| Database | Val Town SQLite (std/sqlite) |
| Runtime | Val Town (Deno) |