26 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, inline CSS/JS helpers, 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)
- The
fe()Helper - Inline
cssandjsTags - 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 })
Inline CSS
Use the css template tag for scoped inline styles in any page:
import { css } from '@because/hype'
export default () => (
<section>
{css`
.hero {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 4rem 2rem;
text-align: center;
}
`}
<div class="hero">
<h1>Welcome</h1>
</div>
</section>
)
This renders a <style> tag inline. Install the vscode-styled-components extension for syntax highlighting.
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
import { js } from '@because/hype'
export default () => (
<section>
<h1>SSE Demo</h1>
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
{js`
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'
}
`}
</section>
)
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
The fe() Helper
fe() (short for "frontend") lets you define JavaScript functions on the server and use them as inline event handlers in SSR pages. The function bodies are automatically extracted and injected as a <script> before </body>.
Basic usage
import { fe } from '@because/hype'
const showAlert = fe(() => {
alert('ding dong')
})
export default () => (
<section>
<button onclick={showAlert}>Click me</button>
</section>
)
fe() returns a string like "frontendFn0()" which works as an onclick attribute value. The actual function is injected once in a <script> tag at the end of the page.
Passing arguments
Pass server data to frontend functions:
import { fe } from '@because/hype'
const greet = (name: string) => fe((args) => {
alert(`Hello, ${args.name}!`)
}, { name })
export default () => (
<section>
<button onclick={greet('Chris')}>Greet Chris</button>
<button onclick={greet('Alex')}>Greet Alex</button>
</section>
)
Arguments are JSON-serialized and passed at call time. The function body is deduplicated — if the same function is used with different arguments, only one copy of the function is injected.
Deduplication
fe() deduplicates by function body. If you call fe() with the same function multiple times, it only injects one copy:
import { fe } from '@because/hype'
const handleClick = fe(() => {
console.log('clicked!')
})
export default () => (
<section>
{/* Both buttons share the same injected function */}
<button onclick={handleClick}>Button 1</button>
<button onclick={handleClick}>Button 2</button>
</section>
)
Inline css and js Tags
css template tag
Returns a <style> element. Use it anywhere in your JSX:
import { css } from '@because/hype'
export default () => (
<section>
{css`
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
}
`}
<div class="card">
<h2>Card Title</h2>
<p>Card content</p>
</div>
</section>
)
js template tag
Returns a <script> element. The content is transpiled from TypeScript on the server using Bun's transpiler, so you can write TypeScript directly:
import { js } from '@because/hype'
export default () => (
<section>
<div id="output"></div>
{js`
interface TimeData {
formatted: string
timestamp: number
}
function formatTime(ts: number): TimeData {
return {
formatted: new Date(ts).toLocaleTimeString(),
timestamp: ts
}
}
const el = document.getElementById('output')!
setInterval(() => {
el.textContent = formatTime(Date.now()).formatted
}, 1000)
`}
</section>
)
Interpolation
Both tags support template literal interpolation:
const primaryColor = '#3498db'
export default () => (
<section>
{css`
h1 { color: ${primaryColor}; }
`}
<h1>Styled heading</h1>
</section>
)
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