Features
Everything index97 provides, all in one place.
Template syntax
| Syntax | Behavior |
|---|---|
| {{variable}} | HTML-escaped output. Safe for user content. |
| {{{variable}}} | Raw HTML output. Use for trusted content like rendered Markdown. |
| {{#if key}}…{{/if}} | Renders block if key is truthy. |
| {{#unless key}}…{{/unless}} | Renders block if key is falsy. |
| {{#each list}}…{{/each}} | Iterates over an array. Context inside is each item. |
| {{!key}} | Outputs the boolean negation of key as a string. |
| {{key.nested}} | Dot-notation path resolution. |
Layout slots
| Syntax | Where | Behavior |
|---|---|---|
| {{content}} | _layout.html | Where page content is injected. |
| {{slot:name}} | _layout.html | Named slot. Empty if page doesn't provide it. |
| {{slot:name || default}} | _layout.html | Named slot with fallback default value. |
| <template data-slot="name">…</template> | .phtml page | Provides content for a named layout slot. |
Handler exports
A .js handler file can export any HTTP method as an async function:
export async function GET(req) { … }
export async function POST(req) { … }
export async function PUT(req) { … }
export async function PATCH(req) { … }
export async function DELETE(req) { … }
Return values:
| Return value | Behavior |
|---|---|
Plain object { key: value } | Merged with the paired .phtml template and rendered as HTML. |
Response object | Returned as-is. Use for redirects, errors, JSON, etc. |
| HTML string | Returned as text/html response. |
Dynamic routes
Wrap a filename segment in square brackets to make it dynamic:
pages/blog/[slug].js → /blog/:slug
pages/users/[id]/posts.js → /users/:id/posts
Access params via req.params.slug, req.params.id, etc.
Form method override
Use any HTTP verb in your HTML forms. index97 rewrites them automatically:
<form method="DELETE" action="/index97/posts/">
<button type="submit">Delete</button>
</form>
Becomes method="POST" with a hidden _method=DELETE field. The handler receives a proper DELETE request.
Server-side includes
The parent's data — from either a .phtml page or a .js handler — is always available for @key resolution.
<!-- Inherit full parent context -->
<include src="partials/nav.phtml">
<!-- Pass a specific key from parent data -->
<include src="partials/user-card.phtml" user="@currentUser">
<!-- Pass a literal string -->
<include src="partials/alert.phtml" type="warning">
Markdown pages
Create .md files with optional YAML front matter. Front matter keys map to layout slots:
---
title: My Post
head: |
<meta name="description" content="…">
---
# My Post
Content here.
Layout data provider
Create _layout.js alongside _layout.html to provide request-aware data to the layout:
// _layout.js
import { getSession } from './_auth.js'
Available template variables
export function data(req) {
const session = getSession(req)
return { session }
}
The layout can then use {{#if session}}, {{session.email}}, etc.
Error pages
| File | Handles |
|---|---|
| _404.html | 404 Not Found errors |
| _error.html | All other errors (500, 401, etc.) |
Available template variables: {{status}}, {{title}}, {{message}}. Error pages are found by walking up from the failing route's directory.
Bun APIs used
| API | Use |
|---|---|
| Bun.serve() | HTTP server with file-based routes and WebSocket support |
| bun:sqlite | SQLite database (use in your _db.js files) |
| Bun.password | Argon2id password hashing and verification |
| Bun.markdown.html() | Markdown → HTML rendering |
| Bun.YAML.parse() | Front matter parsing |
| Bun.file() | Fast file reading |
| Bun.Glob | File discovery during route scanning |
Composing multiple apps
createRoutes() discovers routes from a pagesDir and returns a Bun-compatible routes object without creating a server. Use it to merge a second app's routes into your own createServer() call — one port, no proxy.
import { createServer, createRoutes } from '@devchitchat/index97'
const chatRoutes = await createRoutes({
pagesDir: './node_modules/@devchitchat/chat/pages',
prefix: '/chat',
})
const server = await createServer({
pagesDir: './pages',
port: 3000,
routes: chatRoutes,
})
See Composing apps for the full guide including static files, WebSocket routes, and the setup() + onServerReady() pattern.
Hot reload (dev mode)
Enable with dev: true in createServer(). A file watcher broadcasts changes via SSE:
- CSS files — re-stamps
<link>href with?t=timestamp. No flash, no scroll reset. - All other files — fetches the updated HTML and morphs the live DOM in place using
DOMParser. No full page reload, no lost scroll position or state.
The HMR endpoint is /__index97_hmr. The client script is injected automatically in dev mode.
CLI commands
| Command | Description |
|---|---|
| index97 dev [pagesDir] | Start development server with hot reload. Defaults to current directory. |
| index97 start [pagesDir] | Start production server. No hot reload. |
| index97 build [pagesDir] | Generate static site into dist/. |
| index97 serve [dir] | Serve a pre-built static directory. Defaults to dist/. |
All commands accept --port <n>. build also accepts --out <dir> (default: dist).
Static site generation
index97 build renders all routes to static HTML files and copies public/ assets to the output directory.
- Static routes (
.phtml,.html,.md) are always included. - Dynamic routes (
[param].js) must export astaticPaths()function that returns the list of param objects to render. Routes without it are skipped with a warning.
// blog/[slug].js
import { getAllPosts, getPost } from './_db.js'
export async function GET(req) {
const post = getPost(req.params.slug)
if (!post) return new Response('', { status: 404 })
return { post }
}
export async function staticPaths() {
return getAllPosts().map(p => ({ slug: p.slug }))
}
Output structure mirrors the URL paths, with each page written as index.html inside a directory — so /blog/hello-world becomes dist/blog/hello-world/index.html.