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 the default server framework on JoripSpace. Built on the Web-standard Request and Response APIs, it provides routing, shared middleware and server-rendered pages. This guide walks through building and deploying an app with product listing and detail pages.
The example entry point connects routes and shared error handling. Views and feature code live in separate files so they are easier to find as the app grows. The server files are bundled together 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 includes public product pages and expired-session cleanup. The other guides build on it with authentication, payments and realtime features. Check for existing file and table names before adapting it to another app.
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.