Skip to content

Repository files navigation

index97

A Bun-native web framework. File-based routing, server-side templates, zero config.


Prerequisites

Install Bun:

curl -fsSL https://bun.sh/install | bash

Phase 1 — Up and running in 5 minutes

1. Create a project

mkdir my-site && cd my-site
bun init -y
bun add @devchitchat/index97

2. 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.js

Open http://localhost:3000. Edit any file — the browser updates instantly.


Phase 2 — Level up

Dynamic routes

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>

Templates

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

Layout slots

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}}

Server-side layout data

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 with PUT / PATCH / DELETE

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)
}

Static site generation

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 site

Composing multiple apps on one port

createRoutes() 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
})

createRoutes(options)

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))
}

CLI

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/

Project layout

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.


Security

Content Security Policy

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.

About

A Bun.js web framework that tries its best to stay close the web in principles.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages