Bun + Hono + React Router — a small, opinionated starting point for building an SSR website.
This isn't a framework. It's a wired-up template: React Router 7 in SSR mode, served by a Hono app on Bun in production, with Vite + HMR in development. Env validation, structured logging, security headers, optional local HTTPS, and a tiny design-token system are already in place so you can skip the boilerplate and start building pages.
bun create @holmok/bhrr my-appThis copies the repo into my-app/, runs bun install, and initializes a fresh git repo. Then:
cd my-app
bun devSee Renaming the project below for the handful of strings to swap.
| Runtime | Bun 1.3.9 (pinned) |
| Framework | React Router 7 (SSR) |
| Prod server | Hono via react-router-hono-server |
| Dev server | Vite 8 with React + Hono plugins |
| UI | React 19 |
| Styling | Restyle CSS-in-JS + design tokens |
| Validation | Zod (env schema) |
| Logging | Pino (pretty in dev, JSON in prod) |
| Lint/format | Biome 2 |
| Types | TypeScript ^6 strict, verbatimModuleSyntax |
bun install
bun dev # http://localhost:5173 (Vite)Build and run the production server:
bun run build # runs check + typecheck + react-router build
bun start # NODE_ENV=production bun ./build/server/index.jsThat's it. No node, no npm, no pnpm.
| Script | What it does |
|---|---|
bun run dev |
Start the Vite dev server with HMR. |
bun run build |
prebuild runs bun run check (Biome) and bun typecheck (react-router typegen + tsc), wipes ./build/, then builds. |
bun run start |
Run the built server. Production only — uses Hono on Bun. |
bun run typecheck |
Regenerate React Router route types into .react-router/types/, then tsc. Run after adding or renaming files under app/routes/. |
bun run typegen |
Just the typegen step. |
bun run check |
Biome lint + format check. |
bun run check:fix |
Biome lint + format write. Run before committing. |
bun test |
Bun's built-in test runner. Single file: bun test path/to/file.test.ts. Single test: bun test -t "name pattern". Colocate *.test.ts next to the code under test. |
app/
├── server.ts # Hono server: load context, security headers, optional TLS
├── config.ts # zod-validated env → typed config + pino options
├── env.d.ts # augments react-router's AppLoadContext
├── root.tsx # SSR root: Layout = Main, plus ErrorBoundary
├── reset.css # global resets + content element defaults
├── routes.tsx # flatRoutes() from @react-router/fs-routes
├── routes/ # file-based routes (_index.tsx → /, about.tsx → /about, …)
└── components/
├── values.ts # design tokens (colors, sizes, transitions, breakpoints)
├── layout/ # Main, Header, Footer, Content, Page, Wrapper
└── style/ # styled primitives (HeaderLogo, NavLinks/NavLink, TextButton)
public/ # static assets served at /
react-router.config.ts # ssr: true
vite.config.ts # plugin order matters: honoServer BEFORE reactRouter
biome.json # single quotes, no semis, 2-space, 132 col
The real entry is app/server.ts. bun.lock is a byproduct of bun install and is checked in on purpose.
app/server.ts calls createHonoServer({ runtime: 'bun' }). In dev, Vite plugins handle routing/HMR; in prod, the Hono server (./build/server/index.js) handles the request lifecycle, calls React Router's request handler, and streams the SSR response.
Do not swap this for raw Bun.serve(). Add HTTP middleware in the configure(app) callback inside server.ts.
Loaders and actions receive a typed context argument from getLoadContext() in server.ts:
// app/server.ts
getLoadContext() {
return { config, logger }
}The shape is declared in app/env.d.ts:
declare module 'react-router' {
interface AppLoadContext {
readonly logger: pino.Logger
readonly config: typeof import('./config')
}
}To add something to context (db client, session store, etc.), extend AppLoadContext in env.d.ts and add the value in getLoadContext(). Then any loader/action can pull it from context.
app/config.ts parses process.env through a Zod schema. Parse failure logs each issue and process.exit(1)s — env validation is fatal by design, not best-effort.
Bun auto-loads .env. Do not add dotenv.
Current schema (NODE_ENV, HOST, PORT, LOG_NAME, LOG_LEVEL) — add fields to the Zod schema and they become available as typed exports.
Pino with a pino-pretty transport in dev and a structured-JSON formatter in prod ({ level: 'info', ... }). Request logs go through hono-pino middleware. Available in any loader/action via context.logger.
A Hono middleware in server.ts applies on every response:
X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), microphone=(), camera=()
Edit them in one place. Add other cross-cutting HTTP concerns (CSP, HSTS, rate limit) here, not in individual routes.
@react-router/fs-routes with flatRoutes(). Filename → route:
app/routes/_index.tsx→/app/routes/about.tsx→/aboutapp/routes/blog.$slug.tsx→/blog/:slugapp/routes/_auth.login.tsx→/loginunder a pathless_authlayout
Each route imports its generated types from ./+types/<name>. Those types live in .react-router/types/ and only exist after bun run typegen (or bun run typecheck) has run.
app/root.tsx exports Layout = Main. Per React Router 7's convention, Layout wraps both successful renders and the ErrorBoundary, so anything inside Main (header, footer, <Meta />, <Links />, fonts) appears on error pages too.
The default ErrorBoundary handles 404, route errors, and (in dev) shows the stack.
Restyle gives you atomic CSS-in-JS via styled('tag', {...}) or styled(Component, {...}). No build step, no <ThemeProvider>.
Two styling folders:
app/components/layout/— structural pieces (Main,Header,Footer,Content,Page,Wrapper).app/components/style/— small styled primitives (HeaderLogo,NavLinks/NavLink,TextButton).
The single source of truth for colors, type scale, radii, transitions, breakpoints, page widths, and layout offsets. Don't hardcode colors, sizes, or breakpoints in new styled components — add a token (or extend an existing one) instead.
import { color, fontSize, radius } from '../components/values'If runtime theming (e.g. a dark-mode toggle) is ever needed, promote color tokens to CSS variables on :root via Restyle's <GlobalStyles> and reference them as var(--name).
<Page type="wide"> {/* 1024px — default, content pages */}
<Page type="medium"> {/* 768px — legal, settings */}
<Page type="narrow"> {/* 640px — auth, errors */}pageWidth.narrow and breakpoint.mobile are intentionally the same value: a page that fits "narrow" should also stack on mobile.
Lightweight reset + sensible defaults for <a>, <ul>/<li>, <code>, <pre>, <strong>, <em>, heading spacing, and scroll-margin-top for headings (so anchor jumps clear the fixed header). Imported once from root.tsx.
- Biome (
biome.json): single quotes, no semicolons, no trailing commas, 2-space indent, 132-col line width. Runbun run check:fixbefore committing. - TypeScript is
strictwithverbatimModuleSyntax: true— useimport typefor type-only imports. - The
engines.bunfield pins Bun to1.3.9. ThepeerDependencies.typescriptis^6.0.3. - Prefer Bun built-ins over Node equivalents:
Bun.fileovernode:fs,Bun.$overexeca,bun:sqliteoverbetter-sqlite3,Bun.sqloverpg,Bun.redisoverioredis, built-inWebSocketoverws.
- Create
app/routes/<name>.tsx. export default function MyRoute() { return <Page>…</Page> }.- (Optional)
export function meta() { return [{ title: '…' }] }. - (Optional)
export async function loader({ context }: Route.LoaderArgs) { return { … } }. - Run
bun run typegenso./+types/<name>resolves.
- Add to the Zod schema in
app/config.ts. - Export it:
export const myFlag = env.MY_FLAG === 'true'. - Use it:
context.config.myFlagin loaders, orimport * as config from '~/config'on the server.
- Add the type to
AppLoadContextinapp/env.d.ts. - Add the value to the object returned from
getLoadContext()inapp/server.ts. - It's now typed and available as
context.<field>everywhere.
In app/server.ts's configure(app) callback. Standard Hono middleware applies (app.use('*', …)).
import { styled } from 'restyle'
import { color, radius, fontSize } from '../components/values'
export const Badge = styled('span', {
display: 'inline-block',
padding: '2px 8px',
borderRadius: radius.sm,
fontSize: fontSize.sm,
backgroundColor: color.surfaceMuted,
color: color.textMuted
})For prop-driven styling, pass a resolver:
const Alert = styled('div', (props: { tone: 'info' | 'warn' }) => ({
padding: '12px 16px',
borderRadius: radius.md,
backgroundColor: props.tone === 'warn' ? '#fef2c7' : '#e0e7ff'
}))If localhost+2.pem and localhost+2-key.pem (mkcert output) are present and NODE_ENV=production, the server boots over HTTPS via customBunServer.tls. Dev never uses TLS regardless.
brew install mkcert
mkcert -install
mkcert localhost 127.0.0.1 ::1 # produces localhost+2.pem and localhost+2-key.pem
bun run build && bun run start # served on https://localhost:3000The build output is ./build/. Start with:
NODE_ENV=production bun ./build/server/index.jsSet HOST, PORT, LOG_LEVEL via env. Logs are JSON in production — pipe to your collector of choice.
The server runs on Bun. Anywhere you can run a Bun binary (Fly, Railway, a VPS, a container with oven/bun), this works.
This is intentionally a small base. You'll want to add yourself:
- Database / ORM (
bun:sqlite,Bun.sqlfor Postgres, Drizzle, Prisma…) - Auth + sessions
- A form library / validation
- Test suite (the runner is configured; no tests are checked in)
- CI
The point of the template is the wiring — SSR, server, logging, env, security headers, types, styling tokens — so adding the above is straightforward.
Before you start building, replace:
package.json→nameapp/config.ts→ theLOG_NAMEdefault (currently'bhrr')- The header logo text in
app/components/layout/header.tsx - The footer copyright in
app/components/layout/footer.tsx - Page
<meta>tags / titles inapp/routes/*.tsx
UNLICENSED — this is a template. Replace with your own license when you build on it.