Hono + SSR + JWT
What is this about
Section titled “What is this about”This example builds a small Hono API with 3 routes:
GET /pingfor health checks.POST /ssrto SSR a user card.POST /jwtto issue a valid signed JWT for a user.
This example is split into three files:
hono_knitting.ts(the Hono server, plus a Knitting worker pool).hono_componets_ssr.tsx(SSR task: parse + defaults + render).hono_components_jwt.ts(JWT task: validate + sign + return JSON string).
Technologies used (and why)
Section titled “Technologies used (and why)”- Hono: a small, fast routing layer. It keeps the request path minimal so most overhead is in your actual route work.
@hono/node-server: a thin adapter that runs a Honofetchhandler on Node/Bun.- React SSR (
react-dom/server): renders a tiny HTML page for/ssrso you can simulate CPU-heavy server work. hono/jwt: signs a JWT (HS256) for/jwtso the example includes common auth-like CPU work.- Knitting (
knitting): runs selected transforms in a worker pool (threads). This is the core “offload expensive work” technique the example demonstrates.
The JWT route uses hono/jwt and signs with HS256.
Set JWT_SECRET in production.
Deno setup (TSX + npm)
Section titled “Deno setup (TSX + npm)”This example imports TSX and npm packages. For Deno, keep a root deno.json
like this:
{ "nodeModulesDir": "auto", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" }}Without this, TSX files can fail with:
Uncaught SyntaxError: Unexpected token '<'JWT_SECRET="replace-me" bun src/hono_knitting.tsJWT_SECRET="replace-me" deno run -A src/hono_knitting.tsRoute quick checks
Section titled “Route quick checks”# Pingcurl -s http://localhost:3000/ping
# SSR (returns HTML)curl -s http://localhost:3000/ssr \ -H 'content-type: application/json' \ -d '{"name":"Ari","plan":"pro","bio":"Building on Knitting","projects":17}'
# JWT (returns JSON string with token)curl -s http://localhost:3000/jwt \ -H 'content-type: application/json' \ -d '{"user":{"id":"u_42","email":"ari@example.com","role":"admin"},"ttlSec":900}'Performance notes
Section titled “Performance notes”Measurements: Hono 16-core benchmark.
Saturating mixed load — throughput
Section titled “Saturating mixed load — throughput”One worker, with /ping, /ssr, and /jwt loaded concurrently.
| Route | Hono only | Hono + Knitting | Delta |
|---|---|---|---|
/ping | 2,815 rps | 8,486 rps | +201% |
/ssr | 2,815 rps | 4,816 rps | +71% |
/jwt | 2,578 rps | 4,712 rps | +83% |
Fixed 6,000 RPS mixed load — latency
Section titled “Fixed 6,000 RPS mixed load — latency”One worker, with each route offered 2,000 rps.
| Route | Hono only p50 | Knitting p50 | Hono only p99 | Knitting p99 |
|---|---|---|---|---|
/ping | 0.81ms | 0.40ms | 16.93ms | 2.31ms |
/ssr | 0.84ms | 2.22ms | 14.32ms | 8.71ms |
/jwt | 1.03ms | 2.58ms | 25.18ms | 9.76ms |
Why this pattern matters
Section titled “Why this pattern matters”pingstays cheap and synchronous.- You can benchmark workers vs host-only with the same route behavior.
- In Knitting mode, the JWT task returns stringified JSON to reduce structured-clone overhead.
- Heavy route logic stays in one shared file, keeping both server entrypoints small.
import { serve } from "@hono/node-server";import { createPool } from "knitting";import { Hono } from "hono";import { issueJwt } from "./hono_components_jwt.ts";import { renderSsrPage } from "./hono_componets_ssr.tsx";
const handlers = createPool({})({ issueJwt, renderSsrPage,});
async function main() { const app = new Hono();
app.get("/ping", (c) => { return c.json({ ok: true, pong: true, runtime: process.release?.name ?? "unknown", ts: new Date().toISOString(), }); });
app.post("/ssr", async (c) => { const html = await handlers.call.renderSsrPage(c.req.arrayBuffer()); return c.html(html); });
app.post("/jwt", async (c) => {
const responseJson = await handlers.call.issueJwt(c.req.arrayBuffer());
return c.body(responseJson ?? "Bad request", responseJson ? 200 : 400, { "content-type": "application/json; charset=utf-8", }); });
const server = serve({ fetch: app.fetch, port: 3000 }, (info) => { console.log("GET /ping"); console.log("POST /ssr body: { name?, plan?, bio?, projects? }"); console.log("POST /jwt body: { user: { id, email?, role? }, ttlSec? }"); });
const close = () => { // IMPORTANT TO CLOSE CONNECTION handlers.shutdown(); server.close(); };
process.on("SIGINT", close); process.on("SIGTERM", close);}
main().catch((error) => { console.error(error); process.exitCode = 1;});import React from "react";import { renderToString } from "react-dom/server";import { task } from "knitting";import { z } from "zod";
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
type SsrInput = { name: string; plan: "free" | "pro"; bio: string; projects: number;};
function UserCard({ user }: { user: SsrInput & { updatedAt: string } }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{`${user.name} - SSR Card`}</title> <style> {` body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #f7f8fa; color: #111827; } main { min-height: 100vh; display: grid; place-items: center; padding: 24px; } article { width: min(680px, 100%); background: #fff; border: 1px solid #e5e7eb; border-radius: 16px; padding: 20px; } h1 { margin: 0 0 8px; font-size: 1.4rem; } p { margin: 0 0 10px; line-height: 1.45; } .meta { color: #4b5563; font-size: 0.92rem; display: flex; gap: 12px; flex-wrap: wrap; } .pill { display: inline-block; background: #eef2ff; color: #4338ca; border-radius: 999px; padding: 4px 10px; font-weight: 600; } `} </style> </head> <body> <main> <article> <h1>{user.name}</h1> <p>{user.bio}</p> <div className="meta"> <span className="pill">{user.plan.toUpperCase()} plan</span> <span>{user.projects.toLocaleString()} projects</span> <span>Rendered at {user.updatedAt}</span> </div> </article> </main> </body> </html> );}
const ParsedJsonObjectSchema = z.string().transform((raw, ctx) => { try { const parsed = JSON.parse(raw) as unknown; if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; } return parsed as Record<string, unknown>; } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; }});
const RawSsrInputSchema = z.object({ name: z.preprocess((value) => { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }, z.string().optional()), plan: z.preprocess( (value) => (value === "free" || value === "pro" ? value : undefined), z.enum(["free", "pro"]).optional(), ), bio: z.preprocess((value) => { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }, z.string().optional()), projects: z.preprocess((value) => { const numberValue = Number(value); if (!Number.isFinite(numberValue)) return undefined; return Math.max(0, Math.min(100_000, Math.floor(numberValue))); }, z.number().int().optional()),});
const SsrInputSchema = RawSsrInputSchema.transform( (value): SsrInput => ({ name: value.name ?? "Anonymous", plan: value.plan ?? "free", bio: value.bio ?? "No bio yet.", projects: value.projects ?? 0, }),);
export function renderSsrPageHost(rawPayload: ArrayBuffer): string { let decodedPayload = ""; try { decodedPayload = utf8Decoder.decode(rawPayload); } catch { decodedPayload = ""; }
const parsed = ParsedJsonObjectSchema.safeParse(decodedPayload); const user: SsrInput = SsrInputSchema.parse( parsed.success ? parsed.data : {}, ); const html = renderToString( <UserCard user={{ ...user, updatedAt: new Date().toISOString() }} />, );
return `<!doctype html>${html}`;}
export const renderSsrPage = task<ArrayBuffer, string>({ f: renderSsrPageHost,});import { sign } from "hono/jwt";import { task } from "knitting";import { z } from "zod";
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const ParsedJsonObjectSchema = z.string().transform((raw, ctx) => { try { const parsed = JSON.parse(raw) as unknown; if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; } return parsed; } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; }});
const JwtUserSchema = z.object({ id: z.string().min(1), email: z.string().email().optional(), role: z.string().min(1).optional(),});
const TtlSecSchema = z.preprocess((value) => { const n = Number(value); if (!Number.isFinite(n)) return 900; return Math.max(30, Math.min(86_400, Math.floor(n)));}, z.number().int());
const JwtPayloadSchema = z.object({ user: JwtUserSchema, ttlSec: TtlSecSchema.optional().default(900),});
export async function issueJwtHost( rawPayload: ArrayBuffer,): Promise<string | null> { let decodedPayload: string; try { decodedPayload = utf8Decoder.decode(rawPayload); } catch { return null; }
const parsedResult = ParsedJsonObjectSchema.safeParse(decodedPayload); if (!parsedResult.success) { return null; }
const payloadResult = JwtPayloadSchema.safeParse(parsedResult.data); if (!payloadResult.success) { return null; }
const { user, ttlSec } = payloadResult.data; const now = Math.floor(Date.now() / 1000); const exp = now + ttlSec;
const token = await sign( { sub: user.id, email: user.email, role: user.role ?? "member", iat: now, exp, }, process.env.secret ?? "hello", );
return JSON.stringify({ ok: true, token, sub: user.id, exp, });}
export const issueJwt = task<ArrayBuffer, string | null>({ f: issueJwtHost,});