Features

Everything index97 provides, all in one place.

Template syntax

SyntaxBehavior
{{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

SyntaxWhereBehavior
{{content}}_layout.htmlWhere page content is injected.
{{slot:name}}_layout.htmlNamed slot. Empty if page doesn't provide it.
{{slot:name || default}}_layout.htmlNamed slot with fallback default value.
<template data-slot="name">…</template>.phtml pageProvides 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 valueBehavior
Plain object { key: value }Merged with the paired .phtml template and rendered as HTML.
Response objectReturned as-is. Use for redirects, errors, JSON, etc.
HTML stringReturned 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

FileHandles
_404.html404 Not Found errors
_error.htmlAll 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

APIUse
Bun.serve()HTTP server with file-based routes and WebSocket support
bun:sqliteSQLite database (use in your _db.js files)
Bun.passwordArgon2id password hashing and verification
Bun.markdown.html()Markdown → HTML rendering
Bun.YAML.parse()Front matter parsing
Bun.file()Fast file reading
Bun.GlobFile 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

CommandDescription
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 a staticPaths() 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.