Skip to content

holmok/bhrr

Repository files navigation

bhrr

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.


Use this template

bun create @holmok/bhrr my-app

This copies the repo into my-app/, runs bun install, and initializes a fresh git repo. Then:

cd my-app
bun dev

See Renaming the project below for the handful of strings to swap.


Stack

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

Quickstart

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.js

That's it. No node, no npm, no pnpm.


Scripts

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.

Project structure

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.


Architecture

SSR through Hono on Bun

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.

Load context

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.

Env validation

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.

Logging

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.

Security headers

A Hono middleware in server.ts applies on every response:

  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), microphone=(), camera=()

Edit them in one place. Add other cross-cutting HTTP concerns (CSP, HSTS, rate limit) here, not in individual routes.

Routing

@react-router/fs-routes with flatRoutes(). Filename → route:

  • app/routes/_index.tsx/
  • app/routes/about.tsx/about
  • app/routes/blog.$slug.tsx/blog/:slug
  • app/routes/_auth.login.tsx/login under a pathless _auth layout

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.

Root layout & error boundary

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.


Styling

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).

Design tokens — app/components/values.ts

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 widths

<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.

Global resets — app/reset.css

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.


Conventions

  • Biome (biome.json): single quotes, no semicolons, no trailing commas, 2-space indent, 132-col line width. Run bun run check:fix before committing.
  • TypeScript is strict with verbatimModuleSyntax: true — use import type for type-only imports.
  • The engines.bun field pins Bun to 1.3.9. The peerDependencies.typescript is ^6.0.3.
  • Prefer Bun built-ins over Node equivalents: Bun.file over node:fs, Bun.$ over execa, bun:sqlite over better-sqlite3, Bun.sql over pg, Bun.redis over ioredis, built-in WebSocket over ws.

Cookbook

Add a route

  1. Create app/routes/<name>.tsx.
  2. export default function MyRoute() { return <Page>…</Page> }.
  3. (Optional) export function meta() { return [{ title: '…' }] }.
  4. (Optional) export async function loader({ context }: Route.LoaderArgs) { return { … } }.
  5. Run bun run typegen so ./+types/<name> resolves.

Add an env var

  1. Add to the Zod schema in app/config.ts.
  2. Export it: export const myFlag = env.MY_FLAG === 'true'.
  3. Use it: context.config.myFlag in loaders, or import * as config from '~/config' on the server.

Add a load-context field

  1. Add the type to AppLoadContext in app/env.d.ts.
  2. Add the value to the object returned from getLoadContext() in app/server.ts.
  3. It's now typed and available as context.<field> everywhere.

Add HTTP middleware

In app/server.ts's configure(app) callback. Standard Hono middleware applies (app.use('*', …)).

Add a styled component

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'
}))

Optional: local HTTPS

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:3000

Deployment

The build output is ./build/. Start with:

NODE_ENV=production bun ./build/server/index.js

Set 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.


What's not included

This is intentionally a small base. You'll want to add yourself:

  • Database / ORM (bun:sqlite, Bun.sql for 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.


Renaming the project

Before you start building, replace:

  • package.jsonname
  • app/config.ts → the LOG_NAME default (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 in app/routes/*.tsx

License

UNLICENSED — this is a template. Replace with your own license when you build on it.

About

Bun + Hono + React Router — a small, opinionated starting point for building an SSR website.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors