
Public
LikereactRouter7MinimalExample
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v3View latest version
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.
- SSR + Hydration — Server renders full HTML, client hydrates with
hydrateRoot - Shared route tree — Same
routes.tson server and client;serverLoader/serverActionkeeps DB code off the client - Single-fetch
dataStrategy— One request per navigation viaX-Data-Requestheader - Loaders — Server-side data loading before render
- Actions — Form submissions via
<Form method="post">withintentpattern - SQLite persistence — Val Town's project-scoped database
- Loading states —
useNavigation()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:
- On the server: dynamically import the real loader/action and call it
- On the client: return a no-op stub (the
dataStrategyhandles 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 |
- Fork this val on Val Town
- The database auto-initializes on first request
- Visit the HTTP endpoint to see the app
- Start extending!
| Layer | Technology |
|---|---|
| Server | Hono |
| Routing | React Router 7 |
| UI | React 18 |
| Styling | Twind (Tailwind via CDN) |
| Database | Val Town SQLite (std/sqlite@14-main) |
| Runtime | Val Town (Deno) |