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