Project structure
Keep routes thin and move logic into plain modules:
app/ # routes only: pages, layouts, API routes
layout.tsx
index.tsx
products/[slug]/index.tsx
api/cart/index.ts
_components/ # page-only components (folders starting with _ are not rendered as pages)
components/ # shared UI
lib/ # data access, validation, business logic
content/ # Markdown/MDX, JSON fixtures
public/ # static files (robots.txt, images, fonts)
buntal.config.ts- One responsibility per file: a page renders,
lib/fetches and validates. - Import with the
@/alias (@/lib/db) instead of long relative paths. - Put database and secret-using code only in
$loaders, API routes andlib/modules they import. Buntal strips$from the browser bundle, but components also run in the browser. - Start from a template (
landing,blog) to get this structure, aBun.SQLdatabase layer and SEO routes out of the box.
Data loading
- Use
$for page data, not client-sidefetchinuseEffect. It renders on the server, so content is in the HTML for users and crawlers, and it is re-fetched as JSON on client navigation. - Query the database directly in
$. Calling your own API route from$adds a network hop for nothing. - Use a static
$object (export const $ = { _meta: {...} }) for pages that do not depend on the request. It is inlined into the bundle and costs nothing at request time. - Return a
Responseto redirect or deny (Response.redirect(new URL('/login', req.url), 302)). It works on full loads and client navigation. - Type the props from the loader:
data?: Awaited<ReturnType<typeof $>>. - Handle the empty case.
datacan beundefinedwhile a client navigation is loading.
SEO
Every Buntal page is server-rendered, so crawlers get full HTML without running JavaScript. Make the most of it:
Titles and descriptions
Render <Meta> once in the root layout with site defaults, and override per page through data._meta:
// app/layout.tsx
<Meta
title="Acme"
description="Acme makes the best mugs."
og={{ site_name: 'Acme', type: 'website' }}
twitter={{ card: 'summary_large_image' }}
{...data?._meta}
/>// app/products/[slug]/index.tsx
export const $ = async (req: Req) => {
const product = await getProduct(req.params.slug)
return {
product,
_meta: {
title: `${product.name} | Acme`,
description: product.summary.slice(0, 155),
og: {
type: 'product',
url: `https://acme.com/products/${product.slug}`,
image: product.imageUrl
}
} satisfies MetaProps
}
}- Unique
title(50 to 60 characters) anddescription(up to about 155) on every page. - Use absolute URLs for
og.urlandog.image; images ideally 1200x630. - Set
<html lang="...">in the root layout.
Canonical URLs
React 19 hoists <link> and <meta> rendered anywhere into <head>, so a page can declare its own canonical URL. Use your public origin, not req.url, so proxies and preview hosts do not leak in:
<link rel="canonical" href={`https://acme.com/products/${data.product.slug}`} />Pick one URL per page (with or without trailing slash, www or not) and redirect the others at your proxy or CDN.
Structured data
Add JSON-LD for rich results. Escape < so content cannot close the script tag:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: data.product.name,
image: data.product.imageUrl,
offers: { '@type': 'Offer', price: data.product.price, priceCurrency: 'USD' }
}).replace(/</g, '\\u003c')
}}
/>Common types: Organization and WebSite on the home page, Article for posts, Product for store pages, BreadcrumbList for nested sections.
Sitemap and robots.txt
Serve a sitemap from an API route so it always matches your data:
// app/sitemap.xml/index.ts
import { h } from '@buntal/http'
const ORIGIN = 'https://acme.com'
export const GET = h(async (_, res) => {
const paths = ['/', '/about', ...(await listProductSlugs()).map((s) => `/products/${s}`)]
const urls = paths.map((p) => `<url><loc>${ORIGIN}${p}</loc></url>`).join('')
return res
.headers({
'content-type': 'application/xml; charset=utf-8',
'cache-control': 'public, max-age=3600'
})
.send(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`
)
})And a static public/robots.txt:
User-agent: *
Allow: /
Disallow: /admin
Sitemap: https://acme.com/sitemap.xmlAn RSS feed works the same way (app/rss.xml/index.ts with application/rss+xml).
Not found and private pages
Unknown URLs render app/404.tsx with status 404. When a dynamic route's $ finds nothing, the page still renders with status 200, so tell crawlers not to index it:
export default function ProductPage({ data }: { data?: Awaited<ReturnType<typeof $>> }) {
if (!data?.product) {
return (
<>
<meta name="robots" content="noindex" />
<h1>Product not found</h1>
</>
)
}
// ...
}Add <meta name="robots" content="noindex" /> to admin, account, cart and search-result pages too, and disallow them in robots.txt.
Content
- One
<h1>per page, then<h2>/<h3>in order. - Descriptive link text (not "click here") and
alttext on meaningful images. - Use
<Link>for internal links: it renders a real<a href>that crawlers follow. - Human-readable URLs:
/products/blue-mug, not/products?id=123.
Performance
- Pages are code-split. Each route is lazy-loaded, so keep heavy libraries (editors, charts) inside the pages that need them.
- Do less in
$. Run independent queries in parallel withPromise.all, select only needed columns, and paginate lists. - Cache expensive, shared data in memory in a
lib/module (with a TTL). Loader responses themselves areprivate, no-storeby design, so do not rely on CDN caching for them. - Put a CDN in front of static files. Buntal serves
public/and the bundle without long cache headers; cache/root.js, chunks, fonts and images at the edge./root.jsis versioned with yourpackage.jsonversion, so bump it on each release. - Images: set
widthandheightto avoid layout shift, useloading="lazy"below the fold, and serve modern formats (WebP/AVIF) at the right size. - Fonts: preconnect to the font host in the root layout and use
display=swap, or self-host inpublic/. - Use Bun built-ins (
bun:sqlite,Bun.password,Bun.file) instead of extra dependencies.
Accessibility
- Semantic elements:
<header>,<nav>,<main>,<footer>,<button>for actions,<a>/<Link>for navigation. - Every form input has a
<label>; show errors next to the field and announce them witharia-live. - Keep a visible focus style and make every interaction keyboard-reachable.
- Colour contrast of at least 4.5:1 for text; support dark mode with
prefers-color-scheme. - Respect
prefers-reduced-motionfor animations.
Security
Follow the Security guide. In short:
- Validate every request body, query and param on the server.
- Session cookies:
httpOnly,secure,sameSite: 'Lax'. cors({ origin: [...] })with an explicit allow list;secureHeaders()with a Content-Security-Policy.- Never build file paths or SQL from raw input.
- Secrets in environment variables; only
BUNTAL_PUBLIC_*reaches the browser.
Errors and logging
- Add
app/404.tsxwith your branding and links back to key pages. - In API routes, return a consistent shape:
{ error: string, details?: unknown }with the right status (400, 401, 403, 404, 409, 422). - Use
logger()from@buntal/http/middlewares, and send unexpected errors to an error tracker. In production, users only see a generic message.
Testing
-
API routes and handlers:
bun testwith a server on port 0:import { expect, test } from 'bun:test' import { Http } from '@buntal/http' test('GET /ping', async () => { const app = new Http({ port: 0 }) app.get('/ping', (_, res) => res.json({ pong: 1 })) const server = app.start() const resp = await fetch(`http://localhost:${server.port}/ping`) expect(await resp.json()).toEqual({ pong: 1 }) server.stop(true) }) -
Logic in
lib/: plain unit tests, no server needed. -
Types: run
bunx tsc --noEmitin CI. -
Pages: check the production build with
bun run build && bun startbefore releasing.
Deployment
- Build with
bun run buildand runbun start(setsNODE_ENV=production, listens onPORT, default 3000). - Run behind a reverse proxy or CDN that terminates TLS and sets
x-forwarded-proto, sosecureHeaders()can send HSTS. - Configure secrets (
JWT_SECRET, database URLs) as environment variables, never in the repo. - Keep data files (such as SQLite databases) on a persistent volume.
buntal startserves from.buntal/, so resolve relative paths fromprocess.env.BUNTAL_ROOT(the project folder), as the templates do, and run migrations as a deploy step. - Add a health route (
app/api/health/index.tsreturning{ ok: true }) for your load balancer.
Working with AI agents
Point your agent at /llms-full.txt and add this page's rules to your project's AGENTS.md or CLAUDE.md. Ready-made prompts are in Build with AI.
Last modified: 2026-10-02