Hono + SSR + JWT
What is this about?
Section titled “What is this about?”This example builds a small Hono API with three routes:
GET /pingfor health checks.POST /ssrto render a user card as HTML.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 CPU work typical of authentication routes.- Knitting (
knitting): runs selected transforms in a worker pool (threads). This keeps expensive route work off the request thread.
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 |
CPU cost at the fixed load
Section titled “CPU cost at the fixed load”The latency improvement did not come from doing materially more CPU work. The
host-only server used 1.08 server-process cores. The primary one-worker run
used 1.10, and an equivalent repeat of the same one-worker configuration used
1.06.
Read those measurements as approximately 1.1 cores for both configurations, not as a
meaningful difference between 1.08 and 1.10. The SSR and JWT computation
does not disappear when it moves to the worker. Knitting keeps the transport,
waiting, and coordination cost low enough that the same work costs about the
same CPU while /ping no longer queues behind it.
These are workload-specific 15-second runs. The full report documents the methodology, repeat variance, and limitations; they are evidence for the design, not a universal performance guarantee.
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,});