
A full-stack SSR + SPA reference architecture for Val Town, built with React Router 7, Hono, and SQLite. Ships as a community message board demo, but the patterns here are designed to be extracted into a reusable framework/template.
Live demo: https://react-router-7-example.val.run/
Val Town gives you serverless TypeScript functions, but building a real full-stack app with SSR, client-side navigation, loaders, actions, and hydration requires wiring together several pieces. This project demonstrates one clean way to do it — and is the seed for a potential Val Town Router framework.
Rendering mermaid diagram...
Rendering mermaid diagram...
- Server-Side Rendering — Every page is fully rendered on first visit (SEO-friendly, fast paint)
- Client-Side Navigation — After hydration, navigation is instant SPA-style
- Single-Fetch Data Strategy — Client navigation makes one request per route transition, not one per loader
- Loaders & Actions — Remix-style data loading and form handling, co-located with route components
- Redirect Handling — Server actions return redirects as JSON for the client
dataStrategyto follow - SQLite Persistence — Val Town's built-in SQLite with idempotent migrations and seed data
- Loading States —
useNavigation()for global loading indicators
├── backend/
│ ├── index.ts # Hono HTTP entry point (SSR + data API + static files)
│ └── database/
│ ├── migrations.ts # Schema creation + seed data
│ └── queries.ts # All database queries
├── frontend/
│ ├── index.html # HTML shell template
│ ├── index.tsx # Client entry — hydration + dataStrategy
│ └── components/
│ ├── App.tsx # (unused legacy — see routes/App.tsx)
│ ├── LoadingSpinner.tsx
│ ├── MessageForm.tsx
│ ├── MessageList.tsx
│ ├── SearchForm.tsx
│ └── SearchResults.tsx
├── routes/
│ ├── App.tsx # Root layout (Outlet + nav + footer)
│ ├── Home.tsx # Home page — topic list + create form
│ ├── Home.loader.ts # Loads all topics
│ ├── Topic.tsx # Topic detail — messages + reply form
│ ├── Topic.loader.ts # Loads topic + messages
│ ├── Topic.action.ts # Creates a message in a topic
│ ├── TopicMessage.loader.ts # Direct link to a specific message
│ ├── Topics.action.ts # Creates a new topic (root action)
│ ├── Search.tsx # Search results page
│ └── Search.loader.ts # Runs search query
├── shared/
│ ├── routes.ts # Route tree (shared between server + client)
│ └── types.ts # TypeScript interfaces (Topic, Message, SearchResults)
├── README.md
└── TODO.md
The same route tree is used on both server and client. The serverLoader() / serverAction() helpers make this work:
- On the server: dynamically import the real loader/action module and call it
- On the client: return a no-op stub (the
dataStrategyhandles fetching)
This means loader/action code (and its database dependencies) never ships to the browser.
Instead of React Router calling each loader individually on the client, a custom dataStrategy makes one HTTP request with a X-Data-Request: true header. The server runs all matched loaders/actions and returns their data as keyed JSON.
On full page loads:
createStaticHandlerruns loaderscreateStaticRouter+StaticRouterProviderrenders to HTML string- HTML is injected into the
index.htmltemplate - Client picks up with
hydrateRoot+createBrowserRouter
Form submissions go through React Router actions. On the server, the action returns a redirect(). For data requests, the server converts this to { redirect: "/path" } JSON so the client dataStrategy can follow it, triggering a client-side navigation + loader re-fetch.
| URL | Description |
|---|---|
/ | Home — list of topics + create form |
/topics/:topicId | Topic detail — messages + reply form |
/topics/:topicId/messages/:messageId | Deep link to a specific message |
/search?q=query | Search results across topics and messages |
| Layer | Technology | Purpose |
|---|---|---|
| Server | Hono | HTTP routing, static files, SSR orchestration |
| Routing | React Router 7 | SSR + SPA routing, loaders, actions |
| UI | React 18 | Components + hydration |
| Styling | Twind | Tailwind-in-JS (CDN) |
| Database | Val Town SQLite | Persistence via @stevekrouse/sqlite |
| Runtime | Val Town (Deno) | Serverless hosting |
This project is designed to be the starting point for a reusable Val Town fullstack framework. Here's what could be extracted:
serverLoader()/serverAction()pattern — Generic helpers that work with any route treedataStrategyimplementation — The single-fetch client strategy is app-agnostic- SSR pipeline in
backend/index.ts— The Hono → StaticHandler → render → hydrate flow is generic X-Data-Requestprotocol — Simple convention for distinguishing SSR vs data-only requests
-
createValTownRouter(routes, options)— A single function that returns a Hono app with SSR, static file serving, and the data-request protocol baked in. Users just supply routes. -
Centralized version pinning — React, React Router, and React DOM versions are repeated across many files via
esm.shURLs. A framework could export these from a singledeps.tsmanifest. -
createRoutes()helper — A typed helper that wraps theserverLoader/serverActionpattern so users don't need to understand the server/client bifurcation:createRoutes([ { path: "/", Component: App, children: [ { index: true, Component: Home, loader: "./Home.loader.ts" }, { path: "topics/:id", Component: Topic, loader: "./Topic.loader.ts", action: "./Topic.action.ts" }, ]} ]) -
HTML template generation — Instead of a manual
index.html, the framework could generate the shell with the right scripts, twind, and catch handler included. -
CLI /
initscript — AcreateValTownAppscript or Val Town template that scaffolds the directory structure. -
Middleware hooks — Auth, logging, error boundaries, etc. as composable Hono middleware.
- Should this use Val Town's project-scoped SQLite (
std/sqlite@14-main) or user-scoped (@stevekrouse/sqlite)? - How much of the Hono boilerplate can be hidden vs left explicit for power users?
- Could file-based routing (à la Next.js/Remix) be implemented on top of this?
- What's the right story for CSS — keep Twind CDN, or support CSS modules / Tailwind build?
- Fork this val on Val Town
- The database auto-initializes with seed data on first request
- Visit the HTTP endpoint to see the app
- Modify routes in
shared/routes.ts, add components inroutes/, and queries inbackend/database/
All dependencies are loaded via esm.sh with pinned versions:
react@18.2.0react-dom@18.2.0react-router@7.5.0hono@4.1.0@valtown/std/utils(forreadFile/serveFile)@stevekrouse/sqlite(Val Town SQLite wrapper)