HonoX Framework
This site is built with HonoX, a meta-framework for Hono that adds file-based routing and server-side rendering to Cloudflare Workers.
What is HonoX?
HonoX is a minimal meta-framework built on top of Hono that provides:
- File-based routing - Routes defined by file structure in
app/routes/ - Server-side rendering - SSR with Hono JSX (not React)
- Zero runtime overhead - Compiles to vanilla Hono apps
- Cloudflare Workers optimized - Designed for edge deployment
Unlike full-stack frameworks like Next.js or Remix, HonoX is intentionally minimal - it's just routing + SSR on top of Hono.
File-Based Routing
Routes are created by adding files to app/routes/:
app/routes/
├── index.tsx → /
├── about.tsx → /about
└── content/
└── [...slug].tsx → /content/* (catch-all)
Each route exports a default function created with createRoute():
// app/routes/index.tsx
import { createRoute } from 'honox/factory';
export default createRoute(async (c) => {
return c.html(
<html lang="en">
<head>
<title>Home</title>
</head>
<body>
<h1>Welcome</h1>
</body>
</html>
);
});
Catch-All Routes
Dynamic routes use bracket syntax:
// app/routes/content/[...slug].tsx
export default createRoute(async (c) => {
const slug = c.req.path.replace('/content/', '');
// Fetch content from R2
const content = await getContent(slug);
return c.html(
<html lang="en">
<head>
<title>{content.title}</title>
</head>
<body>
<article>{content.html}</article>
</body>
</html>
);
});
Server-Side Rendering with Hono JSX
HonoX uses Hono JSX for SSR, not React. This is critical to understand.
Hono JSX vs React JSX
Hono JSX (server-side):
- Lightweight JSX implementation
- Synchronous rendering only
- No hooks, no state, no effects
- Returns HTML strings
- Import source:
hono/jsx
React JSX (client-side):
- Full React with hooks, state, effects
- Used for interactive islands only
- Import source:
react
Using Hono JSX in Routes
All route files use Hono JSX:
// tsconfig.json
{
"compilerOptions": {
"jsxImportSource": "hono/jsx" // ← Global default
}
}
This means all .tsx files in app/routes/ and app/components/ use Hono JSX automatically.
Type Annotations
Use FC from hono/jsx for component typing:
import type { FC } from 'hono/jsx';
interface Props {
title: string;
children?: any;
}
export const Layout: FC<Props> = ({ title, children }) => {
return (
<html lang="en">
<head>
<title>{title}</title>
</head>
<body>{children}</body>
</html>
);
};
The Hono Context Object
Every route receives a Hono context object (c):
export default createRoute(async (c) => {
// Access request
const url = c.req.url;
const path = c.req.path;
const query = c.req.query('q');
// Access environment (Cloudflare bindings)
const content = await c.env.CONTENT.get('file.md');
const searchIndex = c.env.AI_SEARCH_INDEX;
// Set headers
c.header('Content-Type', 'text/html; charset=UTF-8');
// Return responses
return c.html(<html>...</html>);
return c.json({ data: 'value' });
return c.text('Plain text');
return c.redirect('/other-page');
});
Environment Bindings
Cloudflare bindings are available via c.env:
import type { Env } from '@/types';
const app = new Hono<{ Bindings: Env }>();
export default createRoute(async (c) => {
// R2 buckets
const mdFile = await c.env.CONTENT.get('docs/intro.md');
const cssFile = await c.env.STATIC.get('style.css');
// AI Search
const results = await c.env.AI.autorag(c.env.AI_SEARCH_INDEX).search({
query: 'test',
});
return c.json({ results });
});
Returning HTML
Method 1: c.html() (Recommended for Components)
Use c.html() when returning JSX directly:
export default createRoute(async (c) => {
return c.html(
<html lang="en">
<head>
<title>Page Title</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
);
});
Method 2: c.body() (Required for DOCTYPE)
Use c.body() when you need to prepend raw strings like <!DOCTYPE html>:
export default createRoute(async (c) => {
const html = (
<html lang="en">
<head>
<title>Page Title</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
);
c.header('Content-Type', 'text/html; charset=UTF-8');
return c.body(`<!DOCTYPE html>${html}`);
});
Why? Putting <!DOCTYPE html> inside JSX gets HTML-escaped. Using c.body() with string concatenation keeps it as-is.
Components
Create reusable server components in app/components/:
// app/components/DocsHeader.tsx
import type { FC } from 'hono/jsx';
interface Props {
title: string;
}
export const DocsHeader: FC<Props> = ({ title }) => {
return (
<header>
<h1>{title}</h1>
<nav>
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
</header>
);
};
Import and use in routes:
// app/routes/index.tsx
import { DocsHeader } from '@/components/DocsHeader';
export default createRoute(async (c) => {
return c.html(
<html>
<body>
<DocsHeader title="Documentation" />
</body>
</html>
);
});
API Routes
Create JSON APIs by returning c.json():
// app/routes/api/search.ts
import { Hono } from 'hono';
import type { Env } from '@/types';
const app = new Hono<{ Bindings: Env }>();
app.post('/', async (c) => {
const { query } = await c.req.json();
const results = await c.env.AI.autorag(c.env.AI_SEARCH_INDEX).search({
query,
max_num_results: 10,
rerank: true,
});
return c.json({
results: results.data,
count: results.data.length,
});
});
export default app;
Note: API routes export a Hono app instance, not createRoute().
Build Process
HonoX compiles to a standard Hono app during build:
pnpm build
# → Outputs to dist/index.js (Cloudflare Worker)
The build process:
- Vite: Bundles server code
- HonoX plugin: Generates route manifest
- Output: Single Worker script with all routes
Development
# Remote development (recommended)
pnpm dev:remote
# Uses wrangler dev --remote to run against Cloudflare
Why remote? This project requires Cloudflare-specific bindings (R2, AI Search) that aren't available locally.
Server vs Client Rendering
Server-side (Hono JSX):
- Renders on every request
- No JavaScript sent to client
- Fast, SEO-friendly
- Use for static content, layouts, navigation
Client-side (React Islands):
- Renders in browser
- JavaScript bundle required
- Interactive, stateful
- Use for search dialogs, theme switchers, dynamic forms
See React Islands for client-side interactivity.
Middleware
Add middleware with app.use():
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
const app = new Hono();
// Global middleware
app.use('*', logger());
app.use('/api/*', cors());
// Route-specific middleware
app.use('/admin/*', async (c, next) => {
// Check auth
const token = c.req.header('Authorization');
if (!token) return c.json({ error: 'Unauthorized' }, 401);
await next();
});
Performance
HonoX is extremely fast because:
- No virtual DOM - Direct HTML string generation
- No hydration overhead - Server components don't ship JS
- Edge deployment - Runs on Cloudflare Workers globally
- Minimal bundle size - Only client islands ship JavaScript
Typical response times:
- HTML pages: 50-150ms (including R2 fetch)
- API routes: 100-300ms (AI Search query)
- Static assets: 10-50ms (cached at edge)
Limitations
Because HonoX uses Hono JSX (not React):
- ❌ No
useState,useEffect, or React hooks in server components - ❌ No async components (workaround: fetch before rendering)
- ❌ No component lifecycle methods
- ✅ Use React islands for interactivity
- ✅ Use middleware for request-level logic
Folder Structure
app/
├── routes/ # File-based routes (Hono JSX)
│ ├── index.tsx # Homepage
│ ├── api/ # API routes
│ └── content/ # Dynamic content routes
├── components/ # Reusable server components (Hono JSX)
│ ├── DocsHeader.tsx
│ └── DocsSidebar.tsx
├── islands/ # Client-side React components
│ ├── Search.tsx
│ └── ThemeSwitcher.tsx
└── client.tsx # Client-side hydration entry
Best Practices
1. Keep Server Components Simple
Server components should be presentation-only:
// ✅ Good - Simple, presentational
export const ArticleCard: FC<{ title: string; url: string }> = ({ title, url }) => {
return (
<article>
<h2>{title}</h2>
<a href={url}>Read more</a>
</article>
);
};
// ❌ Bad - Complex logic, side effects
export const ArticleCard: FC<{ slug: string }> = async ({ slug }) => {
// This won't work - async components not supported
const data = await fetch(`/api/article/${slug}`);
return <article>{data.title}</article>;
};
Instead, fetch data in the route:
export default createRoute(async (c) => {
const slug = c.req.param('slug');
const article = await getArticle(slug);
return c.html(
<ArticleCard title={article.title} url={article.url} />
);
});
2. Use TypeScript Strictly
Define proper types for all props and context:
import type { FC } from 'hono/jsx';
import type { Context } from 'hono';
import type { Env } from '@/types';
interface Props {
title: string;
items: Array<{ id: string; name: string }>;
}
export const List: FC<Props> = ({ title, items }) => {
return (
<div>
<h2>{title}</h2>
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};
3. Organize Routes Logically
app/routes/
├── index.tsx # Homepage
├── about.tsx # Static pages
├── content/
│ └── [...slug].tsx # Dynamic content
└── api/
├── search.ts # Search API
└── manifest.ts # Manifest API
4. Extract Reusable Logic
Create utility functions for common operations:
// app/lib/content.ts
export async function getMarkdownFile(
bucket: R2Bucket,
path: string
): Promise<string | null> {
const file = await bucket.get(path);
if (!file) return null;
return await file.text();
}
// Use in routes
export default createRoute(async (c) => {
const content = await getMarkdownFile(c.env.CONTENT, 'docs/intro.md');
return c.html(<article>{content}</article>);
});
Resources
- Hono Docs: hono.dev
- HonoX Repo: github.com/honojs/honox
- Cloudflare Workers: developers.cloudflare.com/workers
Learn More
- R2 Storage - Content storage and cache busting
- React Islands - Client-side interactivity
- Semantic Search - AI-powered search