
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 | Project-scoped persistence via std/sqlite@14-main |
| 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
Rendering mermaid diagram...
-
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 and get a working full-stack app:import { createValTownRouter } from "https://esm.town/v/std/valtown-router"; import { routes } from "./shared/routes.ts"; export default createValTownRouter(routes, { title: "My App" }); -
Centralized
deps.tsversion pinning — React, React Router, and React DOM versions are repeated across many files viaesm.shURLs (currently 7+ files all referencereact@18.2.0). A framework could export these from a single manifest:// deps.ts — one place to update versions export { default as React } from "https://esm.sh/react@18.2.0"; export { hydrateRoot } from "https://esm.sh/react-dom@18.2.0/client"; export { createBrowserRouter, ... } from "https://esm.sh/react-router@7.5.0?deps=react@18.2.0,react-dom@18.2.0"; -
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" }, ]} ]) -
File-based route discovery — The
routes/directory already follows Remix-style naming conventions (Home.tsx,Home.loader.ts,Topic.action.ts). A framework could auto-discover routes from the filesystem, eliminating the manualshared/routes.tsfile entirely:routes/ _layout.tsx → root layout (App) _index.tsx → / (Home) _index.loader.ts → Home loader topics.$id.tsx → /topics/:id (Topic) topics.$id.loader.ts topics.$id.action.ts search.tsx → /search search.loader.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. No more forgetting to add<script src="https://esm.town/v/std/catch"></script>. -
Project-scoped SQLite by default✅ Done! — Migrated from@stevekrouse/sqlite(user-scoped) tostd/sqlite@14-main(project-scoped). Each fork now gets its own isolated database automatically. -
Middleware hooks — Auth, logging, error boundaries, CORS, etc. as composable Hono middleware that plugs into the framework:
export default createValTownRouter(routes, { middleware: [authMiddleware(), corsMiddleware()], errorBoundary: MyErrorBoundary, }); -
CLI /
inittemplate — AcreateValTownAppscript or a Val Town project template that scaffolds the full directory structure with a working starter app in one click.
- How much of the Hono boilerplate can be hidden vs left explicit for power users?
- What's the right story for CSS — keep Twind CDN, support CSS modules, or Tailwind build?
- Should the framework ship as a single Val Town val (importable library) or as an npm package on esm.sh?
- Could hydration data be embedded in the HTML (like Remix does) instead of requiring a separate client fetch on first load?
- 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)@std/sqlite@14-main(Val Town project-scoped SQLite)