22 KiB
Guide to Writing Hype Apps
Hype is a thin, opinionated wrapper around Hono for fast prototyping with Bun. It gives you file-based routing, automatic TypeScript transpilation, SSE, and a default HTML5 layout — all without a build step.
Since Hype extends Hono, every Hono API (get, post, use, on, etc.) works out of the box.
Table of Contents
- Getting Started
- Project Structure
- SSR Apps
- SPA Apps
- Routing
- Layouts
- Pages
- Client-Side JavaScript
- Styling
- Server-Sent Events (SSE)
- Custom API Routes
- Sub-Routers
- Static Files
- Configuration
- Environment Variables
- Path Aliases
- Utility Functions
- Recipes
Getting Started
# add hype to your project
bun add @because/hype
# create your server entry point
mkdir -p src/server src/pages src/css src/client pub
Add scripts to package.json:
{
"scripts": {
"start": "bun run src/server/index.ts",
"dev": "bun run --hot src/server/index.ts"
}
}
Add the required tsconfig.json:
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}
Create your server:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
export default app.defaults
Create your first page:
// src/pages/index.tsx
export default () => (
<section>
<h1>Hello, world!</h1>
</section>
)
Run it:
bun dev
That's it. You have a server-rendered page at http://localhost:3000.
Project Structure
.
├── package.json
├── tsconfig.json
├── pub/ # Static files (served as-is at /)
│ └── img/
│ └── logo.png # => /img/logo.png
├── src/
│ ├── server/
│ │ └── index.ts # Server entry point
│ ├── pages/
│ │ ├── index.tsx # => GET /
│ │ ├── about.tsx # => GET /about
│ │ └── _layout.tsx # Custom layout (optional)
│ ├── client/
│ │ └── main.ts # Client JS (auto-included by default layout)
│ ├── shared/
│ │ └── types.ts # Shared between server and client
│ └── css/
│ └── main.css # App CSS (auto-included by default layout)
src/pages/— SSR pages, one file per routesrc/client/— Client-side TypeScript, transpiled and bundled on demandsrc/shared/— Isomorphic code, available to both server and clientsrc/css/— Stylesheetssrc/server/— Server-only codepub/— Static assets served directly
SSR Apps
SSR is the default mode. Pages in src/pages/ are server-rendered on every request using Hono's JSX engine and wrapped in a layout.
Minimal SSR app
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
export default app.defaults
// src/pages/index.tsx
export default () => (
<section>
<h1>Welcome</h1>
<p>This is server-rendered HTML.</p>
<a href="/about">About</a>
</section>
)
// src/pages/about.tsx
export default () => (
<section>
<h1>About</h1>
<p>This website was made using futuristic internet technologies.</p>
<a href="/"><= Back</a>
</section>
)
The default layout automatically includes src/css/main.css and src/client/main.ts, wraps your page content in <html>, <head>, and <body><main>...</main></body>.
Accessing the request
Page components receive c (the Hono context) and req (the Hono request) as props:
// src/pages/greet.tsx
export default ({ req }) => (
<section>
<h1>Hello, {req.query('name') ?? 'stranger'}!</h1>
</section>
)
Visit /greet?name=Chris to see Hello, Chris!.
For the full Hono context:
// src/pages/debug.tsx
export default ({ c, req }) => {
const ua = req.header('user-agent')
return (
<section>
<h1>Request Info</h1>
<p>URL: {req.url}</p>
<p>User-Agent: {ua}</p>
</section>
)
}
SPA Apps
For a single-page app with client-side rendering, disable the default layout and provide your own HTML shell:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
export default app.defaults
// src/pages/index.tsx
export default () => (
<html lang="en">
<head>
<title>My SPA</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link href="/css/main.css" rel="stylesheet" />
</head>
<body>
<div id="root" />
<script src="/client/app.js" type="module" />
</body>
</html>
)
// src/client/app.tsx
import { render } from 'hono/jsx/dom'
import { useState } from 'hono/jsx/dom'
function App() {
const [count, setCount] = useState(0)
return (
<>
<h1>My SPA</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(c => c && c - 1)}>-</button>
</>
)
}
render(<App />, document.getElementById('root')!)
The client-side app uses hono/jsx/dom for React-like rendering with hooks (useState, useEffect, etc.). Hype automatically transpiles and bundles src/client/app.tsx when the browser requests /client/app.js.
Cache-busting in SPAs
Use the git hash to bust asset caches:
// src/pages/index.tsx
import { $ } from 'bun'
const GIT_HASH = await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'dev')
export default () => (
<html lang="en">
<head>
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
</head>
<body>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</body>
</html>
)
Passing server data to the client
Inject globals via an inline script:
<script dangerouslySetInnerHTML={{
__html: `
window.GIT_HASH = '${GIT_HASH}';
window.API_URL = '${process.env.API_URL}';
`
}} />
Routing
Hype supports two routing layers that work together.
File-based routing
Files in src/pages/ are automatically mapped to GET routes:
| File | URL |
|---|---|
src/pages/index.tsx |
/ |
src/pages/about.tsx |
/about |
src/pages/contact.tsx |
/contact |
Files prefixed with _ are private and won't be served (returns 404). This is used for layouts and other internal files.
Custom routes
Since Hype extends Hono, you can define any route directly:
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
// JSON API
app.get('/api/users/:id', (c) => {
return c.json({ id: c.req.param('id'), name: 'Chris' })
})
// Form handling
app.post('/api/contact', async (c) => {
const body = await c.req.parseBody()
console.log('Message:', body.message)
return c.redirect('/thanks')
})
// Custom HTML
app.get('/custom', (c) => {
return c.html('<h1>Custom page</h1>')
})
export default app.defaults
Custom routes are defined before the file-based routes, so they take priority.
Layouts
Default layout
By default, Hype wraps every page in a simple HTML5 layout that includes:
<meta charset="utf-8"><meta name="viewport">for responsive design<meta name="color-scheme" content="light dark">for automatic dark mode<link>to/css/main.css(yoursrc/css/main.css)<script>loading/client/main.ts(yoursrc/client/main.ts)- Content wrapped in
<body><main>...</main></body>
Optionally includes the CSS reset and/or Pico CSS based on your config.
Custom layout
Create src/pages/_layout.tsx to replace the default layout:
// src/pages/_layout.tsx
import type { FC } from 'hono/jsx'
const Layout: FC = ({ children, title, props }) => (
<html lang="en">
<head>
<title>{title ?? 'My App'}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/css/main.css" rel="stylesheet" />
<script src="/client/main.ts" type="module"></script>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
<footer>Built with Hype</footer>
</body>
</html>
)
export default Layout
The layout receives:
children— the rendered page contenttitle— page title (defaults to'hype')props— theHypePropspassed to the constructor (useful for conditional CSS)
No layout
Disable the layout entirely for full control (used in SPA mode):
const app = new Hype({ layout: false })
Pages
Pages are .tsx files in src/pages/ that export a default function (or raw JSX).
Function export (recommended)
// src/pages/index.tsx
export default ({ c, req }) => (
<section>
<h1>Home</h1>
</section>
)
Static JSX export
// src/pages/about.tsx
export default (
<section>
<h1>About</h1>
</section>
)
Async data in pages
Since pages render on the server, you can use top-level await:
// src/pages/users.tsx
const users = await fetch('https://api.example.com/users').then(r => r.json())
export default () => (
<section>
<h1>Users</h1>
<ul>
{users.map(u => <li>{u.name}</li>)}
</ul>
</section>
)
Note: top-level data is fetched once at import time and cached. For per-request data, use the c context:
// src/pages/profile.tsx
export default async ({ c, req }) => {
const userId = req.query('id')
const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json())
return (
<section>
<h1>{user.name}</h1>
</section>
)
}
Private pages
Prefix a file with _ to prevent it from being served:
src/pages/_layout.tsx # not a route — used as the layout
src/pages/_helpers.tsx # not a route — internal helpers
src/pages/index.tsx # GET /
Client-Side JavaScript
Automatic transpilation
TypeScript files in src/client/ and src/shared/ are automatically transpiled and bundled by Bun when requested by the browser. The URL maps directly to the file path:
| File | URL |
|---|---|
src/client/main.ts |
/client/main.ts |
src/client/app.tsx |
/client/app.js |
src/shared/utils.ts |
/shared/utils.ts |
You can request .ts or .js extensions — Hype resolves .ts and .tsx files automatically.
Module imports
Client-side files can import from each other using relative paths:
// src/client/main.ts
import { initBurger } from './burger'
initBurger()
// src/client/burger.ts
export function initBurger() {
document.addEventListener('click', (ev) => {
const el = (ev?.target as HTMLElement).closest('.burger') as HTMLImageElement
if (!el) return
el.src = '/img/bite.png'
})
}
Imports are bundled — the full dependency graph is included in the output, so the browser only needs one request.
The default layout auto-includes main.ts
When using the default layout, src/client/main.ts is automatically loaded as a module. Just create the file and it works.
Styling
External CSS
Put your styles in src/css/main.css. The default layout auto-includes it:
/* src/css/main.css */
section {
max-width: 500px;
margin: 0 auto;
}
You can also serve additional CSS files from src/css/:
<link href="/css/components.css" rel="stylesheet" />
Pico CSS
Enable the bundled Pico CSS for classless styling:
const app = new Hype({ pico: true })
Or include it in a custom layout:
<link href="/css/pico.css" rel="stylesheet" />
CSS Reset
Enable the bundled CSS reset (Josh W. Comeau's reset):
const app = new Hype({ reset: true })
Or include it in a custom layout:
<link href="/css/reset.css" rel="stylesheet" />
Combining options
const app = new Hype({ pico: true, reset: true })
Server-Sent Events (SSE)
Hype provides app.sse() for streaming data to the browser.
Server
// src/server/index.ts
import { Hype } from '@because/hype'
const app = new Hype()
// Stream the current time every second
app.sse('/api/time', (send) => {
send({ time: Date.now() })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
export default app.defaults
The send function:
- Automatically
JSON.stringifys objects; strings are sent as-is - Accepts an optional second argument for named events:
send(data, 'eventName')
The handler receives (send, c) where c is the Hono context:
app.sse('/api/user-events', (send, c) => {
const userId = c.req.query('userId')
// subscribe to user-specific events...
})
Return a cleanup function to handle client disconnection.
Client
// src/pages/sse.tsx
export default () => (
<section>
<h1>SSE Demo</h1>
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
<script src="/client/main.ts" type="module"></script>
</section>
)
// src/client/main.ts
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl!.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl!.textContent = 'Disconnected'
}
Named events
// Server
app.sse('/api/feed', (send) => {
send({ type: 'user_joined', name: 'Chris' }, 'activity')
send({ message: 'Hello everyone!' }, 'chat')
})
// Client
const events = new EventSource('/api/feed')
events.addEventListener('activity', (e) => {
console.log('Activity:', JSON.parse(e.data))
})
events.addEventListener('chat', (e) => {
console.log('Chat:', JSON.parse(e.data))
})
Test with curl
curl -N http://localhost:3000/api/time
Custom API Routes
Since Hype extends Hono, use any Hono routing method:
import { Hype } from '@because/hype'
const app = new Hype()
// GET with params
app.get('/api/users/:id', (c) => {
return c.json({ id: c.req.param('id') })
})
// POST with body parsing
app.post('/api/users', async (c) => {
const body = await c.req.json()
return c.json({ created: true, name: body.name }, 201)
})
// Form submission
app.post('/contact', async (c) => {
const form = await c.req.parseBody()
console.log(form.email, form.message)
return c.redirect('/')
})
// Delete
app.delete('/api/users/:id', (c) => {
return c.json({ deleted: true })
})
export default app.defaults
Redirect back
Use redirectBack() to redirect to the referrer:
import { Hype, redirectBack } from '@because/hype'
const app = new Hype()
app.post('/api/like', async (c) => {
// ... handle the like
return redirectBack(c, '/') // falls back to '/' if no referrer
})
Sub-Routers
Use Hype.router() to create sub-routers without duplicate middleware:
// src/server/index.ts
import { Hype } from '@because/hype'
import api from './api'
const app = new Hype()
app.route('/api', api)
export default app.defaults
// src/server/api.ts
import { Hype } from '@because/hype'
const api = Hype.router()
api.get('/users', (c) => c.json([{ id: 1, name: 'Chris' }]))
api.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
api.post('/users', async (c) => {
const body = await c.req.json()
return c.json(body, 201)
})
export default api
Hype.router() creates a Hype instance that skips middleware registration (no duplicate logging, static file serving, etc.).
Static Files
Files in pub/ are served at the root URL:
pub/img/logo.png => /img/logo.png
pub/favicon.ico => /favicon.ico
pub/robots.txt => /robots.txt
Use them in pages:
export default () => (
<section>
<img src="/img/logo.png" width="200" />
</section>
)
Configuration
Constructor options
const app = new Hype({
pico: true, // Include Pico CSS (default: false)
reset: true, // Include CSS reset (default: false)
prettyHTML: true, // Pretty-print HTML in dev (default: true in dev, false in prod)
layout: true, // Wrap pages in layout (default: true)
logging: true, // HTTP request logging (default: true)
ok: true, // Add GET /ok healthcheck (default: false)
})
app.defaults
Always export app.defaults as the default export. It returns a Bun.serve-compatible config object:
export default app.defaults
// => { port, fetch, idleTimeout: 255 }
This triggers lazy route registration and auto-port selection. Bun picks it up as the server config when the file is the entry point.
Environment Variables
| Variable | Effect |
|---|---|
PORT |
Server port (default: 3000) |
NODE_ENV |
Set to production to disable auto-port, HTML prettification, and enable transpile caching |
NO_AUTOPORT |
Disable auto-port selection even in dev |
In dev mode, if port 3000 is busy, Hype automatically tries 3001, 3002, etc. (up to 100 attempts).
Path Aliases
The recommended tsconfig.json defines three path aliases:
| Alias | Maps to | Use for |
|---|---|---|
$* |
src/server/* |
Server-only imports |
#* |
src/client/* |
Client-only imports |
@* |
src/shared/* |
Isomorphic imports |
// In server code:
import { db } from '$db' // => src/server/db.ts
// In client code:
import { format } from '@utils' // => src/shared/utils.ts
Utility Functions
Hype exports a collection of helpers from @because/hype (or @because/hype/utils):
Randomness
import { rand, randRange, randItem, randIndex, shuffle, weightedRand, randomId } from '@because/hype'
rand() // 1 or 2 (coin flip)
rand(6) // 1-6 (roll a die)
rand(20) // 1-20 (d20)
randRange(5, 10) // 5-10 inclusive
randItem(['a', 'b', 'c']) // random element
randIndex(['a', 'b', 'c']) // random index (0, 1, or 2)
shuffle([1, 2, 3, 4, 5]) // shuffled copy
weightedRand() // 1-10, lower numbers more likely
randomId() // e.g. "k7x2m1"
Arrays
import { times, unique } from '@because/hype'
times(5) // [1, 2, 3, 4, 5]
unique([1, 1, 2, 2, 3, 3]) // [1, 2, 3]
Colors
import { lightenColor, darkenColor } from '@because/hype'
lightenColor('#3498db', 0.5) // blend halfway to white
darkenColor('#3498db', 0.5) // blend halfway to black
Strings
import { capitalize } from '@because/hype'
capitalize('hello') // "Hello"
Dark mode detection (client-side)
import { isDarkMode } from '@because/hype'
if (isDarkMode()) {
// user prefers dark mode
}
Transpilation
import { transpile } from '@because/hype'
const js = await transpile('./src/client/app.tsx')
// => bundled ESM JavaScript string
Recipes
Form with POST handler
// src/server/index.ts
import { Hype, redirectBack } from '@because/hype'
const app = new Hype()
const messages: string[] = []
app.post('/api/message', async (c) => {
const { message } = await c.req.parseBody()
if (typeof message === 'string') messages.push(message)
return redirectBack(c)
})
app.get('/api/messages', (c) => c.json(messages))
export default app.defaults
// src/pages/index.tsx
export default () => (
<section>
<h1>Guestbook</h1>
<form method="POST" action="/api/message">
<input name="message" placeholder="Leave a message" required />
<button type="submit">Send</button>
</form>
</section>
)
Healthcheck endpoint
const app = new Hype({ ok: true })
// GET /ok => "ok" (200)
SSR with Pico CSS
const app = new Hype({ pico: true, reset: true })
All your pages instantly get clean, classless styling. Just write semantic HTML.
Multiple SSE channels
const app = new Hype()
// Chat messages
app.sse('/api/chat', (send, c) => {
const room = c.req.query('room') ?? 'general'
// subscribe to chat room...
return () => { /* unsubscribe */ }
})
// Live notifications
app.sse('/api/notifications', (send, c) => {
send({ type: 'connected' })
const check = setInterval(async () => {
// check for new notifications...
send({ count: 5 }, 'notification')
}, 5000)
return () => clearInterval(check)
})
Mixing SSR pages with a JSON API
import { Hype } from '@because/hype'
const app = new Hype({ pico: true })
// API routes (JSON)
app.get('/api/data', (c) => c.json({ items: [1, 2, 3] }))
// SSR pages still work via src/pages/
// Custom SSR route:
app.get('/dashboard', (c) => {
return c.html(
<html>
<body>
<h1>Dashboard</h1>
<div id="data"></div>
<script dangerouslySetInnerHTML={{ __html: `
fetch('/api/data')
.then(r => r.json())
.then(d => document.getElementById('data').textContent = JSON.stringify(d))
`}} />
</body>
</html>
)
})
export default app.defaults
Using Hono middleware
Any Hono middleware works with Hype:
import { Hype } from '@because/hype'
import { cors } from 'hono/cors'
import { basicAuth } from 'hono/basic-auth'
const app = new Hype()
// CORS for API routes
app.use('/api/*', cors())
// Basic auth for admin
app.use('/admin/*', basicAuth({
username: 'admin',
password: process.env.ADMIN_PASSWORD!,
}))
export default app.defaults