# Bunext A Next.js-style full-stack meta-framework built on [Bun](https://bun.sh) and React 19. Bunext provides server-side rendering, file-system based routing, Hot Module Replacement (HMR), and client-side hydration — using ESBuild to bundle client assets and Bun's native HTTP server (`Bun.serve`) to handle requests. --- ## Table of Contents - [Requirements](#requirements) - [Installation](#installation) - [Quick Start](#quick-start) - [CLI Commands](#cli-commands) - [Project Structure](#project-structure) - [File-System Routing](#file-system-routing) - [Pages](#pages) - [Basic Page](#basic-page) - [Server Function](#server-function) - [Redirects from Server](#redirects-from-server) - [Custom Response Options](#custom-response-options) - [SEO Metadata](#seo-metadata) - [Dynamic Metadata](#dynamic-metadata) - [Custom Head Content](#custom-head-content) - [Root Layout](#root-layout) - [API Routes](#api-routes) - [Route Config (Body Size Limit)](#route-config-body-size-limit) - [Error Pages](#error-pages) - [Static Files](#static-files) - [Caching](#caching) - [Enabling Cache Per Page](#enabling-cache-per-page) - [Dynamic Cache Control from Server Function](#dynamic-cache-control-from-server-function) - [Cache Expiry](#cache-expiry) - [Cache Behavior and Limitations](#cache-behavior-and-limitations) - [Configuration](#configuration) - [Middleware](#middleware) - [Environment Variables](#environment-variables) - [How It Works](#how-it-works) - [Development Server](#development-server) - [Production Build](#production-build) - [Bundler](#bundler) - [Hot Module Replacement](#hot-module-replacement) - [Request Pipeline](#request-pipeline) --- ## Requirements - [Bun](https://bun.sh) v1.0 or later - TypeScript 5.0+ - React 19 and react-dom 19 (peer dependencies) --- ## Installation Install Bunext as a dependency in your project: ```bash bun add bunext ``` Install required peer dependencies: ```bash bun add react react-dom bun add -d typescript @types/react @types/react-dom ``` --- ## Quick Start 1. Create a minimal project layout: ``` my-app/ ├── src/ │ └── pages/ │ └── index.tsx ├── bunext.config.ts # optional └── package.json ``` 2. Create your first page (`src/pages/index.tsx`): ```tsx export default function HomePage() { return

Hello from Bunext!

; } ``` 3. Start the development server: ```bash bunext dev ``` 4. Open `http://localhost:7000` in your browser. --- ## CLI Commands | Command | Description | | -------------- | ---------------------------------------------------------------------- | | `bunext dev` | Start the development server with HMR and file watching. | | `bunext build` | Bundle all pages for production. Outputs artifacts to `public/pages/`. | | `bunext start` | Start the production server using pre-built artifacts. | ```bash # Development bunext dev # Production build bunext build # Production server (must run build first) bunext start ``` > **Note:** `bunext start` will exit with an error if `public/pages/map.json` does not exist. Always run `bunext build` before `bunext start`. --- ## Project Structure A typical Bunext project has the following layout: ``` my-app/ ├── src/ │ └── pages/ # File-system routes (pages and API handlers) │ ├── __root.tsx # Optional: root layout wrapper for all pages │ ├── index.tsx # Route: / │ ├── about.tsx # Route: /about │ ├── 404.tsx # Optional: custom 404 page │ ├── 500.tsx # Optional: custom 500 page │ ├── blog/ │ │ ├── index.tsx # Route: /blog │ │ └── [slug].tsx # Route: /blog/:slug (dynamic) │ └── api/ │ └── users.ts # API route: /api/users ├── public/ # Static files and bundler output │ └── __bunext/ │ ├── pages/ # Generated by bundler (do not edit manually) │ │ └── map.json # Artifact map used by production server │ └── cache/ # File-based HTML cache (production only) ├── bunext.config.ts # Optional configuration ├── tsconfig.json └── package.json ``` --- ## File-System Routing Bunext uses `Bun.FileSystemRouter` with Next.js-style routing. Pages live in `src/pages/` and are automatically mapped to URL routes: | File path | URL path | | -------------------------------- | ------------- | | `src/pages/index.tsx` | `/` | | `src/pages/about.tsx` | `/about` | | `src/pages/blog/index.tsx` | `/blog` | | `src/pages/blog/[slug].tsx` | `/blog/:slug` | | `src/pages/users/[id]/index.tsx` | `/users/:id` | | `src/pages/api/users.ts` | `/api/users` | Dynamic route parameters (e.g. `[slug]`) are available in the `server` function via `ctx.req.url` or from the `query` field in the server response. --- ## Pages ### Basic Page A page file must export a default React component. The component receives server-side props automatically. ```tsx // src/pages/index.tsx export default function HomePage() { return

Hello, World!

; } ``` ### Server Function Export a `server` function to run server-side logic before rendering. The return value's `props` field is spread into the page component as props, and `query` carries route query parameters. ```tsx // src/pages/profile.tsx import type { BunextPageServerFn } from "bunext/src/types"; type Props = { props?: { username: string; bio: string }; query?: Record; }; export const server: BunextPageServerFn<{ username: string; bio: string; }> = async (ctx) => { // ctx.req — the raw Request object // ctx.url — the parsed URL // ctx.query — query string parameters // ctx.resTransform — optional response interceptor const username = "alice"; const bio = "Software engineer"; return { props: { username, bio }, }; }; export default function ProfilePage({ props }: Props) { return (

{props?.username}

{props?.bio}

); } ``` The server function receives a `ctx` object (type `BunxRouteParams`) with: | Field | Type | Description | | -------------- | ------------------------------------------------ | ------------------------------------------ | | `req` | `Request` | Raw Bun/Web Request object | | `url` | `URL` | Parsed URL | | `body` | `any` | Parsed request body | | `query` | `any` | Query string parameters | | `resTransform` | `(res: Response) => Promise\|Response` | Intercept and transform the final response | ### Redirects from Server Return a `redirect` object from the `server` function to redirect the client: ```tsx export const server: BunextPageServerFn = async (ctx) => { const isLoggedIn = false; // check auth if (!isLoggedIn) { return { redirect: { destination: "/login", permanent: false, // uses 302 // status_code: 307 // override status code }, }; } return { props: {} }; }; ``` `permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided. ### Custom Response Options Control status codes, headers, and other response options from the server function: ```tsx export const server: BunextPageServerFn = async (ctx) => { return { props: { message: "Created" }, responseOptions: { status: 201, headers: { "X-Custom-Header": "my-value", }, }, }; }; ``` ### SEO Metadata Export a `meta` object to inject SEO and Open Graph tags into the ``: ```tsx import type { BunextPageModuleMeta } from "bunext/src/types"; export const meta: BunextPageModuleMeta = { title: "My Page Title", description: "A description for search engines.", keywords: ["bun", "react", "ssr"], author: "Alice", robots: "index, follow", canonical: "https://example.com/about", themeColor: "#ffffff", og: { title: "My Page Title", description: "Shared on social media.", image: "https://example.com/og-image.png", url: "https://example.com/about", type: "website", siteName: "My Site", locale: "en_US", }, twitter: { card: "summary_large_image", title: "My Page Title", description: "Shared on Twitter.", image: "https://example.com/twitter-image.png", site: "@mysite", creator: "@alice", }, }; export default function AboutPage() { return

About us

; } ``` ### Dynamic Metadata `meta` can also be an async function that receives the request context and server response: ```tsx import type { BunextPageModuleMetaFn } from "bunext/src/types"; export const meta: BunextPageModuleMetaFn = async ({ ctx, serverRes }) => { return { title: `Post: ${serverRes?.props?.title ?? "Untitled"}`, description: serverRes?.props?.excerpt, }; }; ``` ### Custom Head Content Export a `Head` functional component to inject arbitrary HTML into ``. It receives the server response and request context: ```tsx import type { BunextPageHeadFCProps } from "bunext/src/types"; export function Head({ serverRes, ctx }: BunextPageHeadFCProps) { return ( <> ); } export default function Page() { return
Content
; } ``` ### Root Layout Create `src/pages/__root.tsx` to wrap every page in a shared layout. The root component receives `children` (the current page component) along with all server props: ```tsx // src/pages/__root.tsx import type { PropsWithChildren } from "react"; export default function RootLayout({ children, props, }: PropsWithChildren) { return ( <>
My App
{children}
© 2025
); } ``` --- ## API Routes Create files under `src/pages/api/` to define API endpoints. The default export receives a `BunxRouteParams` object and must return a standard `Response`. ```ts // src/pages/api/hello.ts import type { BunxRouteParams } from "bunext/src/types"; export default async function handler(ctx: BunxRouteParams): Promise { return Response.json({ message: "Hello from the API" }); } ``` API routes are matched at `/api/`. Because the handler returns a plain `Response`, you control the status code, headers, and body format entirely: ```ts // src/pages/api/users.ts import type { BunxRouteParams } from "bunext/src/types"; export default async function handler(ctx: BunxRouteParams): Promise { if (ctx.req.method !== "GET") { return Response.json({ error: "Method not allowed" }, { status: 405 }); } const users = [{ id: 1, name: "Alice" }]; return Response.json({ success: true, data: users }); } ``` The `ctx` parameter has the same shape as the page `server` function context — see the [Server Function](#server-function) section for the full field reference. The `ctx.server` field additionally exposes the Bun `Server` instance. ### Route Config (Body Size Limit) Export a `config` object to override the per-route request body limit (default: 10 MB): ```ts import type { BunextServerRouteConfig, BunxRouteParams, } from "bunext/src/types"; export const config: BunextServerRouteConfig = { maxRequestBodyMB: 50, // allow up to 50 MB }; export default async function handler(ctx: BunxRouteParams): Promise { // handle large uploads return Response.json({ success: true }); } ``` --- ## Error Pages Bunext serves built-in fallback error pages, but you can override them by creating pages in `src/pages/`: | File | Triggered when | | ------------------- | --------------------------------------- | | `src/pages/404.tsx` | No matching route is found | | `src/pages/500.tsx` | An unhandled error occurs during render | The error message is passed as `children` to the component: ```tsx // src/pages/404.tsx import type { PropsWithChildren } from "react"; export default function NotFoundPage({ children }: PropsWithChildren) { return (

404 — Page Not Found

{children}

); } ``` If no custom error pages exist, Bunext uses built-in preset components. --- ## Static Files Place static files in the `public/` directory. They are served at the `/public/` URL path: ``` public/ ├── logo.png → http://localhost:7000/public/logo.png ├── styles.css → http://localhost:7000/public/styles.css └── favicon.ico → http://localhost:7000/favicon.ico ``` > Favicons are also served from `/public/` but matched directly at `/favicon.*`. --- ## Caching Bunext includes a file-based HTML cache for production. Caching is **disabled in development** — every request renders fresh. In production, a cron job runs every 30 seconds to delete expired cache entries. Cache files are stored in `public/__bunext/cache/`. Each cached page produces two files: | File | Contents | |---------------------------|----------------------------------------------| | `.res.html` | The cached HTML response body | | `.meta.json` | Metadata: creation timestamp, expiry, paradigm | The cache is **cold on first request**: the first visitor triggers a full server render and writes the cache. Every subsequent request within the expiry window receives the cached HTML directly, bypassing the server function, component render, and bundler lookup. A cache hit is indicated by the response header `X-Bunext-Cache: HIT`. ### Enabling Cache Per Page Export a `config` object from a page file to opt that page into caching: ```tsx // src/pages/products.tsx import type { BunextRouteConfig } from "bunext/src/types"; export const config: BunextRouteConfig = { cachePage: true, cacheExpiry: 300, // seconds — optional, overrides the global default }; export default function ProductsPage() { return

Products

; } ``` ### Dynamic Cache Control from Server Function Cache settings can also be returned from the `server` function, which lets you conditionally enable caching based on request data: ```tsx import type { BunextPageServerFn } from "bunext/src/types"; export const server: BunextPageServerFn = async (ctx) => { const data = await fetchProducts(); return { props: { data }, cachePage: true, cacheExpiry: 600, // 10 minutes }; }; export default function ProductsPage({ props }: any) { return
    {props.data.map((p: any) =>
  • {p.name}
  • )}
; } ``` If both `module.config.cachePage` and `serverRes.cachePage` are set, `module.config` takes precedence. ### Cache Expiry Expiry resolution order (first truthy value wins): 1. `cacheExpiry` on the page `config` export or `server` return (per-page, in seconds) 2. `defaultCacheExpiry` in `bunext.config.ts` (global default, in seconds) 3. Built-in default: **3600 seconds (1 hour)** The cron job checks all cache entries every 30 seconds and deletes any whose age exceeds their expiry. Static bundled assets (JS/CSS in `public/__bunext/`) receive a separate HTTP `Cache-Control: public, max-age=604800` header (7 days) via the browser cache — this is independent of the page HTML cache. ### Cache Behavior and Limitations - **Production only.** Caching never activates in development (`bunext dev`). - **Cold start required.** The cache is populated on the first request; there is no pre-warming step. - **Immutable within the expiry window.** Once a page is cached, `writeCache` skips all subsequent write attempts for that key until the cron job deletes the expired entry. There is no manual invalidation API. - **Cache is not cleared on rebuild.** Deploying a new build does not automatically flush `public/__bunext/cache/`. Stale HTML files referencing old JS bundles can be served until they expire. Clear the cache directory as part of your deploy process if needed. - **Key collision with dashes.** Cache keys are derived by replacing every `/` in the URL path with `-`. This means `/foo/bar` and `/foo-bar` produce the same cache filename and will share a cache entry. Avoid enabling `cachePage` on routes where a nested path and a dash-separated path could collide. --- ## Configuration Create a `bunext.config.ts` file in your project root to configure Bunext: ```ts // bunext.config.ts import type { BunextConfig } from "bunext/src/types"; const config: BunextConfig = { port: 3000, // default: 7000 origin: "https://example.com", distDir: ".bunext", // directory for internal build artifacts assetsPrefix: "_bunext/static", globalVars: { MY_API_URL: "https://api.example.com", }, development: false, // forced by the CLI; set manually if needed }; export default config; ``` | Option | Type | Default | Description | | -------------- | --------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------- | | `port` | `number` | `7000` | HTTP server port | | `origin` | `string` | — | Canonical origin URL | | `distDir` | `string` | `.bunext` | Internal artifact directory | | `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets | | `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time | | `development` | `boolean` | — | Overridden to `true` by `bunext dev` automatically | | `defaultCacheExpiry`| `number` | `3600` | Global page cache expiry in seconds | | `middleware` | `(params: BunextConfigMiddlewareParams) => Response \| undefined \| Promise<...>` | — | Global middleware — see [Middleware](#middleware) | ### Middleware Middleware runs on every request before any routing. Define it in `bunext.config.ts` via the `middleware` field. The function receives `{ req, url, server }` (type `BunextConfigMiddlewareParams`). - If it returns a `Response`, that response is sent to the client immediately and no further routing occurs. - If it returns `undefined` (or nothing), the request proceeds normally through the router. ```ts // bunext.config.ts import type { BunextConfig, BunextConfigMiddlewareParams, } from "bunext/src/types"; const config: BunextConfig = { middleware: async ({ req, url, server }) => { // Example: protect all /dashboard/* routes if (url.pathname.startsWith("/dashboard")) { const token = req.headers.get("authorization"); if (!token) { return Response.redirect("/login", 302); } } // Return undefined to continue to the normal request pipeline return undefined; }, }; export default config; ``` The middleware can return any valid `Response`, including redirects, JSON, or HTML: ```ts middleware: async ({ req, url }) => { // Block a specific path if (url.pathname === "/maintenance") { return new Response("Down for maintenance", { status: 503 }); } // Add CORS headers to all API responses if (url.pathname.startsWith("/api/")) { // Let the request proceed, but transform the response via resTransform on individual routes } }, ``` --- ## Environment Variables | Variable | Description | | -------- | ------------------------------------------------------- | | `PORT` | Override the server port (takes precedence over config) | --- ## How It Works ### Development Server Running `bunext dev`: 1. Loads `bunext.config.ts` and sets `development: true`. 2. Initializes directories (`.bunext/`, `public/pages/`). 3. Creates a `Bun.FileSystemRouter` pointed at `src/pages/`. 4. Starts the ESBuild bundler in **watch mode** — it will automatically rebuild when file content changes. 5. Starts a file-system watcher on `src/` — when a file is created or deleted (a "rename" event), it triggers a full bundler rebuild to update the entry points. 6. Waits for the first successful bundle. 7. Starts `Bun.serve()`. ### Production Build Running `bunext build`: 1. Sets `NODE_ENV=production`. 2. Runs ESBuild once (not in watch mode) with minification enabled. 3. Writes all bundled artifacts to `public/pages/` and the artifact map to `public/pages/map.json`. 4. Exits. ### Production Server Running `bunext start`: 1. Reads `public/pages/map.json` to load the pre-built artifact map. 2. Starts `Bun.serve()` without any bundler or file watcher. ### Bundler The bundler (`allPagesBundler`) uses ESBuild with three custom plugins: - **`tailwindcss` plugin** — Processes any `.css` files through PostCSS + Tailwind CSS before bundling. - **`virtual-entrypoints` plugin** — Generates an in-memory client hydration entry point for each page. Each entry imports the page component and calls `hydrateRoot()` against the server-rendered DOM node. If `src/pages/__root.tsx` exists, the page is wrapped in the root layout. - **`artifact-tracker` plugin** — After each build, collects all output file paths, content hashes, and source entrypoints into a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `public/pages/map.json`. Output files are named `[dir]/[name]/[hash]` so filenames change when content changes, enabling cache-busting. ### Hot Module Replacement In development, Bunext injects a script into every HTML page that opens a **Server-Sent Events (SSE)** connection to `/__hmr`. When a rebuild completes and the bundle hash for a page changes, the server pushes an event through the SSE stream and the client reloads the page automatically. The SSE controller for each connected client is tracked in `global.HMR_CONTROLLERS`, keyed by the page URL and its bundled artifact map entry. On disconnect, the controller is removed from the list. ### Request Pipeline Every incoming request is handled by `Bun.serve()` and routed as follows: ``` Request │ ├── config.middleware({ req, url, server }) │ Returns Response? → short-circuit, send response immediately │ Returns undefined → continue │ ├── GET /__hmr → Open SSE stream for HMR (dev only) │ ├── /api/* → API route handler │ Matches src/pages/api/.ts │ Checks content-length against maxRequestBodyMB / 10 MB default │ Calls module.default(ctx) → returns Response directly │ ├── /public/* → Serve static file from public/ │ ├── /favicon.* → Serve favicon from public/ │ └── Everything else → Server-side render a page [Production only] Check public/__bunext/cache/ for key = pathname + search Cache HIT → return cached HTML with X-Bunext-Cache: HIT header Cache MISS → continue ↓ 1. Match route via FileSystemRouter 2. Find bundled artifact in BUNDLER_CTX_MAP 3. Import page module (with cache-busting timestamp in dev) 4. Run module.server(ctx) for server-side data 5. Resolve meta (static object or async function) 6. renderToString(component) → inject into HTML template 7. Inject window.__PAGE_PROPS__, hydration