Routing and pages

How a request becomes a page: Hono routes, server-rendered JSX, the middleware ordering rule that breaks APIs, client-side behavior without React, and opting routes into the edge cache.

The Spikefrost Team30 Jul 20265 min read

Apps are Hono applications running on Cloudflare Workers, rendering JSX on the server. If you know Express or React, most of it will feel familiar and a few things will bite — this page is mostly about those.

The entry point

app/src/index.tsx creates the app, registers middleware, and defines routes:

import { Hono } from 'hono';
import type { AppType } from './types';
import { Layout } from './components/Layout';

const app = new Hono<AppType>();

app.get('/api/health', (c) => c.json({ ok: true }));

app.get('/', (c) =>
  c.html(
    <Layout title="Home">
      <h1 class="text-4xl font-bold">Hello</h1>
    </Layout>
  )
);

export default app;

c.html() renders JSX to a response. c.json() returns JSON. c.redirect() redirects. There's no app.listen — the platform runs the Worker.

The ordering rule that costs an afternoon

Register specific routes before catch-all middleware. The common case is internationalization:

// ✅ Correct — API routes claimed first
app.get('/api/*', handler);
app.use('/:lang/*', i18nMiddleware());
app.get('/:lang/', homePage);
// ❌ Broken — /:lang/* swallows /api too
app.use('/:lang/*', i18nMiddleware());
app.get('/api/*', handler);   // never reached; "api" is parsed as a language

A /:lang/* pattern matches any first segment. Anything that must not be treated as a locale — /api, /webhooks, /rooms, /_spike/* — goes above it.

Hono, not Express

The build fails on the Express-shaped mistakes, so learn the four replacements:

Instead of Use
c.req.body await c.req.json() or await c.req.parseBody()
c.req.params.id c.req.param('id')
c.req.cookie getCookie(c, 'name') from hono/cookie
process.env.X c.env.X

Forms post lowercase: <form method="post">.

Server-rendered JSX

It looks like React. It is not React. There is no client-side component tree, no hooks, no state — the JSX runs once per request on the server and produces HTML.

Consequences worth stating:

  • Attributes are HTML attributes: class, for, stroke-width.
  • Numeric attributes take numbers: maxlength={32}, rows={4}.
  • Escape anything user-supplied that you interpolate into markup. Scaffolds ship an escapeHtml() helper in app/src/utils/helpers.ts. Server-rendered HTML plus unescaped input is stored XSS.

Client-side behavior

For interactivity, ship a small script with raw() from hono/html — and never interpolate server values into it:

import { raw } from 'hono/html';

export const Counter = ({ start }: { start: number }) => (
  <>
    <button data-counter data-start={start} class="rounded-lg px-4 py-2">
      Clicked 0 times
    </button>
    <script>{raw(`
      document.addEventListener('click', (e) => {
        const btn = e.target.closest('[data-counter]');
        if (!btn) return;
        const n = (Number(btn.dataset.count || btn.dataset.start) + 1);
        btn.dataset.count = String(n);
        btn.textContent = 'Clicked ' + n + ' times';
      });
    `)}</script>
  </>
);

The pattern in three parts: data through data-* attributes, event delegation rather than inline onclick, and no string interpolation into the script body. Interpolating a server value into a script tag is an injection hole, and the build checks for it.

Styling

Tailwind, compiled at build time from app/src/styles.css. Scaffolds define semantic CSS custom properties — --c-surface, --c-text, --c-accent — and semantic utility names (bg-surface, text-text, bg-accent) so an app has one coherent identity instead of raw palette colors sprinkled through templates. Dark mode is handled at the theme level, not with dark: on individual elements.

Edge caching — the largest free win

By default nothing is cached: every request runs your Worker. A response is cached at the edge only when it carries a public s-maxage directive and sets no cookies. Opting the right routes in is dramatic — a cache hit is served from the edge location without running the Worker at all: no database queries, no cold start.

Scaffolds include a helper in app/src/utils/spikefrost.ts:

app.get('/products', async (c) => {
  const { results } = await c.get('db').prepare('SELECT * FROM products').all();
  cachePublic(c, 60);                       // 60s at the edge
  return c.html(<ProductList items={results} />);
});

Or set the header yourself: c.header('Cache-Control', 'public, s-maxage=60, max-age=0').

Opt in to GET routes whose output is identical for every visitor: marketing pages, catalogs, product pages, public API reads.

Never opt in to personalized, per-session, or authenticated responses. Requests carrying a session cookie or Authorization header bypass the cache anyway, but the response itself must also be visitor-independent — a cached response may be served to everyone.

Details that matter in practice:

  • Analytics cookies don't defeat caching. Requests whose only cookies are well-known analytics or consent cookies (_ga, _gid, _fbp, _hj*, OneTrust, Cookiebot, and so on) are treated as anonymous and get cache hits. Two consequences: never personalize server-rendered output based on an analytics cookie, and never name your session cookie like one.
  • Every deploy invalidates the whole app cache. Between deploys, data changes keep being served up to s-maxage — so keep TTLs short (30–300s) for data-driven pages and longer only for genuinely static ones.
  • stale-while-revalidate serves the expired copy instantly while refreshing behind it. Good where a briefly stale page beats a slow one.
  • Assets under /assets/ are cached automatically. Nothing to do.
  • A cache hit produces no request logs. Don't debug "my logs are missing" on a cached route.

Verifying

Two response headers tell you what happened:

curl -sSI https://your-app.example.com/products | grep -i 'x-sf-'
  • x-sf-cacheMISS then HIT on a second cookieless GET. BYPASS means the response didn't opt in. STALE means it was served while revalidating. No header at all means the request wasn't eligible.
  • x-sf-version — which deploy answered. Compare it with the id sf deploy printed.

That second header settles the most common false alarm: for up to about a minute after a deploy, some edge locations may still serve the previous HTML while the version pointer propagates. Don't debug "the deploy didn't work" inside the first minute — check x-sf-version, and add a cachebusting query string if you need to force the new version immediately.

Next

  • Database — querying, migrations, and why one slow query takes down the whole site.
  • Files and assets — serving media and handling uploads.
  • Durable Objects — when a page needs live shared state.

Frequently asked questions

Why did my /api route stop working after I added internationalization?

Almost certainly ordering. A /:lang/* middleware swallows the first path segment, so it captures /api too. Define /api/* routes before that middleware is registered.

How do I add client-side interactivity without React?

Ship a small script with raw() from hono/html, pass server data through data-* attributes, and use event delegation. There is no client-side React on Workers — pages are server-rendered.

How do I make pages fast?

Opt public GET routes into the edge cache with a Cache-Control: public, s-maxage=<n> header. A cache hit is served at the edge without running your Worker at all — no database queries, no cold start.

Why is a page I opted into caching still showing x-sf-cache BYPASS?

The response either didn't carry the public s-maxage header, set a cookie, wasn't a GET, or the request carried a session cookie or Authorization header. All of those bypass by design.

How long does a deploy take to show up?

Usually seconds, but for up to about a minute some edge locations can still serve the previous HTML. Check the x-sf-version response header against the id the deploy printed before concluding something is broken.