Docs / Hono and project structure
Hono and project structure
Organize files by responsibility and take an SSR web app from local development to deployment.
Organize files by responsibility
Hono is a server framework built on the Web-standard Request and Response APIs. Use the JoripSpace Worker entry point for routing, middleware and HTML responses.
Keep the entry point focused on route composition and shared error handling. Maintain views, business logic, database queries and browser code in separate files, then bundle the server source for deployment.
src/
index.ts # route composition
types.ts # server binding types
routes/products.ts # product routes and responses
routes/jobs.ts # scheduled job routes
middleware/session.ts # session validation
views/layout.ts # shared SSR layout
services/ # payment and realtime logic
repositories/ # database access when the app grows
public/ # browser JS, CSS and images
migrations/001_init.sql
tests/
wrangler.jsonc
tsconfig.jsonThe example implements public product list and detail pages plus expired-session cleanup. Do not overwrite existing project files or tables when adapting it.
Install and configure
Run these commands in the connected working directory. Add the dependencies to an existing package instead of replacing its configuration, and commit the generated lockfile.
npm init -y
npm install hono
npm install -D typescript esbuild wrangler @cloudflare/workers-types{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"types": [
"@cloudflare/workers-types"
],
"lib": [
"ES2022",
"DOM"
],
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}{
"name": "docs-local-example",
"main": "src/index.ts",
"compatibility_date": "2026-09-06",
"d1_databases": [
{
"binding": "DB",
"database_name": "docs-local",
"database_id": "00000000-0000-0000-0000-000000000001"
}
]
}The sample database ID is for local development. Production uses the database and storage attached to the JoripSpace project.
Runnable source
export type AppEnv = {
Bindings: { DB: D1Database; STORAGE: R2Bucket; SESSION_HASH_SALT: string };
Variables: { identity: { user_id: string; session_hash: string } };
};import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { products } from './routes/products';
import { jobs } from './routes/jobs';
import type { AppEnv } from './types';
const app = new Hono<AppEnv>();
app.route('/', products);
app.route('/jobs', jobs);
app.get('/health', (c) => c.json({ ok: true }));
app.notFound((c) => c.text('Page not found.', 404));
app.onError((error, c) => {
if (error instanceof HTTPException) return error.getResponse();
// Log a separate error ID instead of the raw request, cookies or payment-provider response.
const errorId = crypto.randomUUID();
console.error('request_failed', errorId);
return c.json({ error: 'internal_error', errorId }, 500);
});
export default app;import { html } from 'hono/html';
export function layout(title: string, description: string, body: ReturnType<typeof html>) {
return html`<!doctype html>
<html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title><meta name="description" content="${description}">
</head><body><header><a href="/">Product list</a></header>
<main>${body}</main></body></html>`;
}import { Hono } from 'hono';
import { html } from 'hono/html';
import { layout } from '../views/layout';
import type { AppEnv } from '../types';
type Product = { id: number; name: string; description: string; price: number };
export const products = new Hono<AppEnv>();
products.get('/', async (c) => {
const raw = c.req.query('after') || '0';
if (!/^\d{1,15}$/.test(raw)) return c.text('Invalid cursor.', 400);
const after = Number(raw);
const { results } = await c.env.DB.prepare(
'SELECT id,name,description,price FROM products WHERE published=1 AND id>? ORDER BY id LIMIT 21'
).bind(after).all<Product>();
const rows = results.slice(0, 20);
const next = results.length > 20 ? rows[rows.length - 1].id : null;
// html escapes string values. Never pass user input to raw().
const body = html`<h1>Product list</h1><ul>${rows.map(p =>
html`<li><a href="/products/${p.id}">${p.name}</a> · ${p.price} KRW</li>`
)}</ul>${next === null ? '' : html`<a href="/?after=${next}">Next page</a>`}`;
c.header('Cache-Control', 'no-cache');
return c.html(layout('Product list', 'Browse products and prices.', body));
});
products.get('/products/:id', async (c) => {
const id = c.req.param('id');
if (!/^\d{1,15}$/.test(id)) return c.notFound();
const p = await c.env.DB.prepare(
'SELECT id,name,description,price FROM products WHERE id=? AND published=1'
).bind(Number(id)).first<Product>();
if (!p) return c.notFound();
c.header('Cache-Control', 'no-cache');
return c.html(layout(p.name, p.description,
html`<article><h1>${p.name}</h1><p>${p.description}</p><p>${p.price} KRW</p></article>`));
});import { Hono } from 'hono';
import type { AppEnv } from '../types';
export const jobs = new Hono<AppEnv>();
jobs.post('/cleanup-sessions', async (c) => {
// The scheduler sends this URL through a private Dispatch call.
// Never authorize a request using only a public header such as X-JoripSpace-Cron-ID.
if (new URL(c.req.url).hostname !== 'joripspace-cron.internal')
return c.json({ error: 'scheduler_required' }, 403);
const result = await c.env.DB.prepare(
'DELETE FROM app_sessions WHERE session_hash IN (' +
'SELECT session_hash FROM app_sessions WHERE expires_at<=? ORDER BY expires_at LIMIT 100)'
).bind(Date.now()).run();
// The job is idempotent; a later schedule processes any remaining items.
return c.json({ ok: true, deleted: result.meta.changes });
});CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
price INTEGER NOT NULL CHECK(price > 0),
published INTEGER NOT NULL DEFAULT 0 CHECK(published IN (0,1))
);
CREATE INDEX idx_products_public ON products(published,id);
INSERT INTO products(id,name,description,price,published)
VALUES(1,'Example product','A public product rendered with SSR.',1000,1);
CREATE TABLE app_sessions (
session_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_expiry ON app_sessions(expires_at,session_hash);The sample database stores public products. Because the session table starts empty, only public product pages are available without authentication.
Run and verify locally
npx wrangler d1 execute docs-local --local --file migrations/001_init.sql
npx tsc --noEmit
npx wrangler devOpen /, /products/1 and /health, then verify that a missing product returns HTTP 404. Apply the initialization SQL once to a new local database and add later changes as new migrations.
Deploy to JoripSpace
npx esbuild src/index.ts --bundle --format=esm --platform=browser --target=es2022 --outfile=dist/worker.js
joripspace db migrate --project PROJECT --file migrations/001_init.sql
joripspace deploy --project PROJECT --source dist/worker.js --label "Hono SSR example"These commands assume a new example project without GitHub deployment. Check for production table conflicts first. A GitHub-connected project must follow its configured branch and deployment workflow.
Keep the editable source, migrations, package manifest and lockfile in Git. After deployment, verify list, detail and 404 responses at the public URL.