A Bun-native web framework. File-based routing, server-side templates, zero config.
Install Bun:
curl -fsSL https://bun.sh/install | bash1. Create a project
mkdir my-site && cd my-site
bun init -y
bun add @devchitchat/index972. Create the entry point
// server.js
import { createServer } from '@devchitchat/index97'
createServer({ pagesDir: './pages' })3. Create your first page and layout
mkdir pages pages/public<!-- pages/_layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{slot:title || My Site}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
{{content}}
</main>
</body>
</html><!-- pages/index.html -->
<template data-slot="title">Home — My Site</template>
<h1>Hello, world.</h1>
<include src="_greeting.phtml"><!-- pages/_greeting.phtml -->
<p>Welcome to index97. Files are routes. No config needed.</p>/* pages/public/style.css */
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
nav a { margin-right: 1rem; }4. Run it
bun server.jsOpen http://localhost:3000. Edit any file — the browser updates instantly.
Wrap a folder name in brackets to make it a parameter.
pages/
blog/
[slug].js ← handles /blog/hello-world
[slug].phtml ← template for the above
// pages/blog/[slug].js
import db from './_db.js'
export async function GET(req) {
const post = db.query('SELECT * FROM posts WHERE slug = ?').get(req.params.slug)
if (!post) return new Response('', { status: 404 })
return { post }
}<!-- pages/blog/[slug].phtml -->
<h1>{{post.title}}</h1>
<p>{{post.body}}</p>| Syntax | What it does |
|---|---|
{{name}} |
Render value, HTML-escaped |
{{{name}}} |
Render value, raw HTML |
{{#if name}}...{{/if}} |
Conditional |
{{#each items}}...{{/each}} |
Loop — {{this}} is each item |
<include src="partial.phtml"> |
Server-side partial |
<include src="partial.phtml" label="@item.label"> |
Pass data to partial |
Pages can inject into named slots in the layout:
<!-- in any page -->
<template data-slot="title">About — My Site</template>
<template data-slot="head">
<link rel="stylesheet" href="/about.css">
</template>
<h1>About</h1><!-- in _layout.html -->
<title>{{slot:title || My Site}}</title>
{{slot:head}}
{{content}}Export a data function from _layout.js to make values available across every page — useful for navigation, session state, feature flags:
// pages/_layout.js
export function data(req) {
const session = getSession(req)
return { session }
}<!-- in _layout.html -->
{{#if session}}<a href="/signout">Sign out</a>{{/if}}Forms only support GET and POST natively. index97 rewrites the others automatically:
<form method="DELETE" action="/posts/42">
<button>Delete</button>
</form>Export the matching method from your handler:
export async function DELETE(req) {
db.run('DELETE FROM posts WHERE id = ?', [req.params.id])
return Response.redirect('/posts', 303)
}Export staticPaths() from any dynamic handler to tell the build which URLs to render:
// pages/blog/[slug].js
export function staticPaths() {
return db.query('SELECT slug FROM posts').all().map(p => ({ slug: p.slug }))
}bunx index97 build # renders all routes to dist/
bunx index97 serve # serves dist/ as a static sitecreateRoutes() separates route discovery from server creation. Use it when you want to run two apps — say, a website and a chat service — on the same port without a proxy.
// server.js
import { createServer, createRoutes } from '@devchitchat/index97'
// Build routes for a second app, prefixed at /chat
const chatRoutes = await createRoutes({
pagesDir: './chat/pages',
prefix: '/chat',
csp: "default-src 'self'; connect-src 'self' ws: wss:",
})
// Add any explicit routes the second app needs
chatRoutes['/chat/ws'] = (req, server) => {
if (server.upgrade(req)) return
return new Response('WebSocket upgrade required', { status: 426 })
}
// Single server — website at / and chat at /chat
const server = await createServer({
pagesDir: './pages',
port: 3000,
routes: chatRoutes, // merged in alongside the website's own routes
websocket: chatWebsocket, // from the second app
})Discovers routes from a pagesDir and returns a Bun-compatible routes object. All patterns are optionally prefixed so they don't collide with the host app's routes.
| Option | Type | Default | Description |
|---|---|---|---|
pagesDir |
string |
— | Directory to discover routes from |
prefix |
string |
"" |
URL prefix prepended to every route pattern |
dev |
boolean |
false |
Inject HMR script into HTML responses |
csp |
string |
default CSP | Content-Security-Policy header value |
permissionsPolicy |
string |
camera=(), microphone=(), geolocation=() |
Permissions-Policy header value |
notFoundPage |
string |
null |
Path to a custom 404 page |
The returned object is a plain Record<string, Function> — pass it directly to createServer() via the routes option, or spread it with other explicit routes before passing.
Static files in pagesDir/public/ are served by createServer()'s built-in fetch handler and are not included in the returned routes object. If the second app's static files need to be served under a prefix, register them as explicit routes:
const glob = new Bun.Glob('**/*')
for await (const file of glob.scan({ cwd: './chat/pages/public', onlyFiles: true })) {
chatRoutes['/chat/' + file] = () => new Response(Bun.file('./chat/pages/public/' + file))
}| Command | What it does |
|---|---|
bunx index97 |
Start dev server with HMR |
bunx index97 start |
Start production server |
bunx index97 build |
Generate static site to dist/ |
bunx index97 serve |
Serve a pre-built dist/ |
my-site/
server.js ← entry point
pages/
_layout.html ← wraps every page
_layout.js ← server-side data for the layout
index.html ← /
about.html ← /about
blog/
index.html ← /blog
[slug].js ← /blog/:slug (handler)
[slug].phtml ← template for the handler
public/
style.css ← served as static files
logo.png
Files starting with _ are private — they are not routes.
index97 sets the following CSP header on every response by default:
default-src 'self'; style-src 'self'; script-src 'self'
This means inline styles and inline scripts are blocked. Use external stylesheets and script files served from pages/public/ instead.
<!-- blocked -->
<div style="color: red">hello</div>
<style>body { margin: 0 }</style>
<!-- allowed -->
<link rel="stylesheet" href="/style.css">To override the CSP for the entire server, pass a csp option to createServer:
import { createServer } from '@devchitchat/index97'
createServer({
pagesDir: './pages',
csp: "default-src 'self'; style-src 'self' 'unsafe-inline'"
})To override the CSP for a single route, set Content-Security-Policy on the response returned by the handler — index97 will leave it untouched:
Bun.serve({
routes: {
'/embed': {
GET: () => new Response('ok', {
headers: { 'Content-Security-Policy': "frame-ancestors 'self' https://example.com" }
})
}
}
})The same pattern works for Permissions-Policy.