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('페이지를 찾을 수 없습니다.', 404));
app.onError((error, c) => {
if (error instanceof HTTPException) return error.getResponse();
// 원문 요청·쿠키·PG 응답 대신 별도 오류 ID를 기록합니다.
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="/">상품 목록</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('잘못된 커서입니다.', 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은 문자열 값을 이스케이프합니다. User 입력을 raw()에 넣지 마세요.
const body = html`<h1>상품 목록</h1><ul>${rows.map(p =>
html`<li><a href="/products/${p.id}">${p.name}</a> · ${p.price}원</li>`
)}</ul>${next === null ? '' : html`<a href="/?after=${next}">다음 페이지</a>`}`;
c.header('Cache-Control', 'no-cache');
return c.html(layout('상품 목록', '상품과 가격을 확인하세요.', 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}원</p></article>`));
});import { Hono } from 'hono';
import type { AppEnv } from '../types';
export const jobs = new Hono<AppEnv>();
jobs.post('/cleanup-sessions', async (c) => {
// 스케줄러는 비공개 Dispatch 호출로 이 URL을 전달합니다.
// 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();
// 반복 실행해도 안전하며, 남은 항목은 다음 스케줄에서 처리합니다.
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,'예제 상품','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.