Moduletrace Org
Jupiter

@moduletrace/bunext (1.3.2)

Published 2026-10-16 17:42:36 +02:00 by tben

Installation

@moduletrace:registry=https://git.tben.me/api/packages/Moduletrace/npm/
npm install @moduletrace/[email protected]
"@moduletrace/bunext": "1.3.2"

About this package

Bunext

A server-rendering framework for React, built entirely on Bun. Bunext handles file-system routing, SSR, HMR, and client hydration — using ESBuild to bundle client assets and Bun.serve as the HTTP server.

Philosophy

Bunext is focused on server-side rendering and processing. Every page is rendered on the server on every request. The framework deliberately does not implement client-side navigation, SPA routing, or client-side state management — those concerns belong in client-side libraries and are orthogonal to what Bunext is solving.

The goal is a framework that is:

  • Fast — Bun's runtime speed and ESBuild's bundling make the full dev loop snappy
  • Transparent — the entire request pipeline is readable and debugable
  • Standard — server functions and API handlers use native Web APIs (Request, Response, URL) with no custom wrappers

Table of Contents


Requirements

  • Bun v1.0 or later
  • TypeScript 5.0+

React is managed by Bunext. You do not need to install react or react-dom — Bunext enforces its own pinned React version and removes any user-installed copies at startup to prevent version conflicts. Installing this package is all you need.


Installation

From the Moduletrace registry (recommended)

Configure the @moduletrace scope to point at the registry — pick one:

bunfig.toml (Bun-native):

[install.scopes]
"@moduletrace" = { registry = "https://git.tben.me/api/packages/moduletrace/npm/" }

.npmrc (works with npm, bun, and most tools):

@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/

Then install:

bun add @moduletrace/bunext

Or globally:

bun add -g @moduletrace/bunext

From GitHub (alternative)

bun add github:moduletrace/bunext

Quick Start

  1. Create a minimal project layout:
my-app/
├── src/
│   └── pages/
│       └── index.tsx
├── bunext.config.ts      # optional
└── package.json
  1. Create your first page (src/pages/index.tsx):
export default function HomePage() {
    return <h1>Hello from Bunext!</h1>;
}
  1. Add scripts to your package.json:
{
    "scripts": {
        "dev": "bunx bunext dev",
        "build": "bunx bunext build",
        "start": "bunx bunext start"
    }
}
  1. Start the development server:
bun run dev
  1. 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 .bunext/public/pages/ using paths relative to the project root.
bunext start Start the production server using pre-built artifacts.
bunext sitemap Generate public/sitemap.xml and public/robots.txt from your pages, using the sitemap and robots options in bunext.config.ts. See Sitemap.

Running the CLI

Bunext exposes a bunext binary. How you invoke it depends on how the package is installed:

Local install (recommended) — add scripts to package.json and run them with bun run:

{
    "scripts": {
        "dev": "bunx bunext dev",
        "build": "bunx bunext build",
        "start": "bunx bunext start"
    }
}
bun run dev
bun run build
bun run start

Global install — install once and use bunext from anywhere:

bun add -g @moduletrace/bunext
bunext dev
bunext build
bunext start

Note: bunext start will exit with an error if .bunext/public/pages/map.json does not exist. Always run bunext build (or bun run 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
│       ├── __root.server.ts    # Optional: root-level server logic
│       ├── index.tsx           # Route: /
│       ├── index.server.ts     # Optional: server logic for index route
│       ├── about.tsx           # Route: /about
│       ├── 404.tsx             # Optional: custom 404 page
│       ├── 500.tsx             # Optional: custom 500 page
│       ├── blog/
│       │   ├── index.tsx       # Route: /blog
│       │   ├── index.server.ts # Server logic for /blog
│       │   └── [slug].tsx      # Route: /blog/:slug (dynamic)
│       └── api/
│           └── users.ts        # API route: /api/users
├── public/                # Static files served at /public/*
├── .bunext/               # Internal build artifacts (do not edit manually)
│   └── public/
│       ├── pages/         # Generated by bundler
│       │   └── 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.

Non-Routed Directories

Directories whose name contains -- or a parenthesis (( or )) are completely ignored by the router. Use this to co-locate helper components, utilities, or shared logic directly inside src/pages/ alongside the routes that use them, without them becoming routes.

Naming pattern Effect
(components)/ Ignored — not routed
--utils--/ Ignored — not routed
--lib/ Ignored — not routed
src/pages/
├── blog/
│   ├── (components)/       # Not a route — co-location directory
│   │   ├── PostCard.tsx    # Used by index.tsx and [slug].tsx
│   │   └── PostList.tsx
│   ├── index.tsx           # Route: /blog
│   └── [slug].tsx          # Route: /blog/:slug
└── index.tsx               # Route: /

Pages

Basic Page

A page file must export a default React component. The component receives server-side props automatically.

// src/pages/index.tsx
export default function HomePage() {
    return <h1>Hello, World!</h1>;
}

Server Function

Server logic lives in a companion .server.ts (or .server.tsx) file alongside the page. The framework looks for <page>.server.ts or <page>.server.tsx next to the page file and loads it separately on the server — it is never bundled into the client JS.

The server file exports the server function as either export default or export const server. The return value's props field is spread into the page component as props, and query carries route query parameters.

// src/pages/profile.server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";

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 server;
// src/pages/profile.tsx  (client-only exports — bundled to the browser)
type Props = {
    props?: { username: string; bio: string };
    query?: Record<string, string>;
    url?: URL;
};

export default function ProfilePage({ props, url }: Props) {
    return (
        <div>
            <h1>{props?.username}</h1>
            <p>{props?.bio}</p>
            <p>Current path: {url?.pathname}</p>
        </div>
    );
}

Server files can also export a sitemap function to list a page's URLs in the sitemap — see Page sitemap Export.

Why separate files? Bundling server code (Bun APIs, database clients, fs, secrets) into the same file as a React component causes TypeScript compilation errors because the bundler processes the page file for the browser. The .server.ts companion file is loaded only by the server at request time and is never included in the client bundle.

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>|Response Intercept and transform the final response

Default Server Props

Every page component automatically receives a url prop — a copy of the request URL object — even if no server function is exported. This means you can always read URL data (pathname, search params, origin, etc.) directly from component props without writing a server function.

// src/pages/index.tsx
import type { BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types";

type Props = {
    url?: BunextPageModuleServerReturnURLObject;
};

export default function HomePage({ url }: Props) {
    return (
        <div>
            <p>Visiting: {url?.pathname}</p>
            <p>Origin: {url?.origin}</p>
        </div>
    );
}

The url prop exposes the following fields from the standard Web URL interface:

Field Type Example
href string "https://example.com/blog?q=1"
origin string "https://example.com"
protocol string "https:"
host string "example.com"
hostname string "example.com"
port string ""
pathname string "/blog"
search string "?q=1"
searchParams URLSearchParams URLSearchParams { q: "1" }
hash string ""
username string ""
password string ""

Redirects from Server

Return a redirect object from the server function to redirect the client:

// src/pages/dashboard.server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";

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: {} };
};

export default server;

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:

// src/pages/submit.server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";

const server: BunextPageServerFn = async (ctx) => {
    return {
        props: { message: "Created" },
        responseOptions: {
            status: 201,
            headers: {
                "X-Custom-Header": "my-value",
            },
        },
    };
};

export default server;

SEO Metadata

Export a meta object from the page file (not the server file) to inject SEO and Open Graph tags into the <head>:

import type { BunextPageModuleMeta } from "@moduletrace/bunext/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 <p>About us</p>;
}

Dynamic Metadata

meta can also be an async function that receives the request context and server response. Like meta, it is exported from the page file:

import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/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 <head>. It receives the server response and request context:

import type { BunextPageHeadFCProps } from "@moduletrace/bunext/types";

export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
    return (
        <>
            <link rel="preconnect" href="https://fonts.googleapis.com" />
            <link rel="icon" href="/public/favicon.ico" />
        </>
    );
}

export default function Page() {
    return <main>Content</main>;
}

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.

If the root layout also needs server-side logic, place it in src/pages/__root.server.ts (or .server.tsx) — the same .server.* convention used by regular pages:

// src/pages/__root.server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";

const server: BunextPageServerFn = async (ctx) => {
    // e.g. fetch navigation links, check auth
    return { props: { navLinks: ["/", "/about"] } };
};

export default server;
// src/pages/__root.tsx
import type { BunextRootComponentProps } from "@moduletrace/bunext/types";

export default function RootLayout({
    children,
    props,
}: BunextRootComponentProps) {
    return (
        <>
            <header>My App</header>
            <main>{children}</main>
            <footer>© 2025</footer>
        </>
    );
}

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.

// src/pages/api/hello.ts
import type { BunxRouteParams } from "@moduletrace/bunext/types";

export default async function handler(ctx: BunxRouteParams): Promise<Response> {
    return Response.json({ message: "Hello from the API" });
}

API routes are matched at /api/<filename>. Because the handler returns a plain Response, you control the status code, headers, and body format entirely:

// src/pages/api/users.ts
import type { BunxRouteParams } from "@moduletrace/bunext/types";

export default async function handler(ctx: BunxRouteParams): Promise<Response> {
    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 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):

import type {
    BunextServerRouteConfig,
    BunxRouteParams,
} from "@moduletrace/bunext/types";

export const config: BunextServerRouteConfig = {
    maxRequestBodyMB: 50, // allow up to 50 MB
};

export default async function handler(ctx: BunxRouteParams): Promise<Response> {
    // 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:

// src/pages/404.tsx
import type { PropsWithChildren } from "react";

export default function NotFoundPage({ children }: PropsWithChildren) {
    return (
        <div>
            <h1>404 — Page Not Found</h1>
            <p>{children}</p>
        </div>
    );
}

If no custom error pages exist, Bunext uses built-in preset components.


Sitemap

Bunext builds a sitemap from every static page in src/pages/. API routes, 404/500 pages and ignored directories are left out. Dynamic routes ([slug]) cannot be discovered from the file system, so they are only included when their server file exports a sitemap function (see below), or when you pass their URLs through additional_paths.

Page sitemap Export

A page's .server.ts file can export a sitemap function returning that page's entries. This is the way to list URLs for a dynamic route:

// src/pages/blog/[slug].server.ts
import type { BunextPageServerFn, BunextPageSitemapFn } from "@moduletrace/bunext/types";

const server: BunextPageServerFn = async (ctx) => {
    // ...
};

export default server;

export const sitemap: BunextPageSitemapFn = async ({ route }) => {
    // route === "/blog/[slug]"
    const posts = await getPublishedPosts();

    return posts.map((post) => ({
        path: `/blog/${post.slug}`,
        lastmod: post.updated_at,
        priority: 0.7,
    }));
};
  • It can be sync or async, and returns strings or BunextSitemapEntry objects, like additional_paths.
  • On a static page, the returned entries replace the page's default entry, so you can set its priority/changefreq, or return [] to leave the page out.
  • exclude and the default_* options still apply to the returned entries.
  • Generating a sitemap imports every page's server file, so their top-level code runs. If a sitemap function throws, generation fails with the file name in the error.

bunext sitemap Command

Add a sitemap key to bunext.config.ts, then run bunext sitemap. By default the file is written to public/sitemap.xml, which is served at /sitemap.xml. Run it after bunext build in your deploy step to keep it current.

// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/types";

const config: BunextConfig = {
    origin: "https://example.com",
    sitemap: {
        exclude: ["/login", "/admin/*"],
        default_changefreq: "weekly",
        // A function (sync or async) lets you load dynamic routes
        additional_paths: async () => {
            const posts = await getPublishedPosts();
            return posts.map((post) => ({
                path: `/blog/${post.slug}`,
                lastmod: post.updated_at,
            }));
        },
    },
};

export default config;
bunext sitemap                                   # writes public/sitemap.xml and public/robots.txt
bunext sitemap -o public/sitemaps/main.xml      # custom sitemap output path
bunext sitemap --origin https://staging.example.com
bunext sitemap --no-robots                       # sitemap only

Last-Modified Dates

lastmod comes from the build pipeline. On every bunext build, Bunext compares each page's client bundle hash with the previous build and records the build date for pages whose bundle changed in .bunext/page-lastmod.json. The hash covers the page's whole import graph, so editing a shared component re-dates every page that uses it, while untouched pages keep their date. Editing __root.tsx changes every page.

Each URL's lastmod is the first of:

  1. The entry's own lastmod (from additional_paths or a page's sitemap export)
  2. The page's date from the build record (also used for entries returned by that page's sitemap export)
  3. The page file's modification time, if use_file_lastmod: true
  4. default_lastmod

With no date, <lastmod> is left out. Run bunext build before bunext sitemap; the command warns when there is no record.

  • The record must persist between builds. Without the previous record, every page is dated with the current build. Building on the server you deploy to keeps .bunext/ between builds. For CI builds from a fresh checkout, set sitemap.lastmod_file to a path you cache between runs or commit (it is sorted JSON for small diffs).
  • Server data is not covered. Content loaded in a server function (e.g. from a database) does not change the bundle, so set lastmod on those entries in the page's sitemap export.
  • Upgrading esbuild or changing build options can change every hash once, re-dating all pages.

Large Sitemaps

A sitemap file may hold at most 50,000 URLs and 50MB. When the sitemap has more than chunk_size URLs (default 10,000), bunext sitemap splits it into chunk files and makes sitemap.xml a sitemap index pointing to them:

public/
├── sitemap.xml                 ← <sitemapindex> referencing the chunks
└── sitemap-chunks/
    ├── sitemap-1.xml           ← up to chunk_size URLs each
    ├── sitemap-2.xml
    └── sitemap-3.xml

Each index entry's lastmod is the newest lastmod in its chunk. Chunk files left over from a previous, larger run are removed; other files in the chunks directory are left alone. If the sitemap shrinks back under chunk_size, sitemap.xml returns to a plain <urlset>.

Config option Type Description
chunk_size number Maximum URLs per file. Default 10000, capped at 50000. Chunks are also split at 50MB.
chunks_dir string Chunk directory relative to the project root. Must be inside public/. Default public/sitemap-chunks.

--output and --origin override sitemap.output and sitemap.origin from the config. --origin also applies to robots.txt. The command also writes a robots.txt — see robots.txt.

Generating at Runtime

bunextGenerateSitemap() resolves to the same XML as a single <urlset> (it does not chunk, and logs a warning above 50,000 URLs — use bunext sitemap for large sites), so you can serve it from a route instead of a file:

// src/pages/api/sitemap.ts
import bunext from "@moduletrace/bunext";

export default async function handler(req: Request) {
    const xml = await bunext.bunextGenerateSitemap({
        exclude: ["/login", "/admin/*", /^\/drafts/],
        additional_paths: async () => {
            const posts = await getPublishedPosts();
            return posts.map((post) => ({
                path: `/blog/${post.slug}`,
                lastmod: post.updated_at,
            }));
        },
        default_changefreq: "weekly",
    });

    return new Response(xml, {
        headers: { "Content-Type": "application/xml" },
    });
}
Option Type Description
origin string Base URL for each <loc>. Defaults to config.origin, then http://localhost:<port>.
exclude (string | RegExp)[] Exact paths, subtrees ending in /* (/admin/* also excludes /admin), or regexes tested on the path.
additional_paths entries | () => entries | Promise<entries> Extra paths or entries (path, lastmod, changefreq, priority), or a sync/async function returning them, e.g. for dynamic routes. Each entry is a string or BunextSitemapEntry.
default_lastmod Date | string lastmod for entries with no other date. See Last-Modified Dates.
lastmod_file string Page date record written by bunext build. Default .bunext/page-lastmod.json.
use_file_lastmod boolean Fall back to each page file's modification time. Default false.
trailing_slash boolean Append / to every non-root path. Default false.
default_changefreq BunextSitemapChangeFreq changefreq for entries that do not set one.
default_priority number priority (0.0 to 1.0) for entries that do not set one.

The sitemap config key takes the same options, plus output (the file path relative to the project root, default public/sitemap.xml) and the chunking options.

robots.txt

bunext sitemap also writes public/robots.txt, served at /robots.txt. With no robots config it allows every crawler and points to the generated sitemap:

# Generated by bunext

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

Configure it with the robots key:

// bunext.config.ts
const config: BunextConfig = {
    origin: "https://example.com",
    robots: {
        rules: [
            { user_agent: "*", disallow: ["/admin", "/api/"] },
            { user_agent: ["GPTBot", "CCBot"], disallow: ["/"] },
        ],
        // Block everything on staging
        disallow_all: process.env.STAGING === "true",
    },
};
Option Type Description
rules BunextRobotsRule[] Groups of user_agent (string or array), allow, disallow and crawl_delay. Default: allow everything for *.
sitemaps string[] Extra Sitemap: URLs or paths. The generated sitemap is added automatically when it is inside public/.
disallow_all boolean Block every crawler from the whole site. rules and sitemaps are ignored.
origin string Base URL for relative sitemaps paths. Defaults to --origin, then sitemap.origin, then config.origin.
output string Output file relative to the project root. Default public/robots.txt.
  • Existing files are protected. Generated files start with # Generated by bunext. Without a robots config, a robots.txt that lacks this line (one you wrote yourself) is left alone and a warning is shown. Setting robots in the config overwrites it.
  • Opting out. Set robots: false, or pass --no-robots, to skip robots.txt entirely.
  • Sitemap exclude does not add Disallow lines. Leaving a page out of the sitemap and blocking crawlers from it are different things; add disallow rules explicitly. Remember robots.txt is public, so Disallow paths are visible to anyone.

To serve it from a route instead, bunext.bunextGenerateRobotsTxt() takes the same options except output and returns the text.

Disabling SEO

For preview or test deployments that must never be indexed, set disable_seo:

// bunext.config.ts
const config: BunextConfig = {
    disable_seo: process.env.PREVIEW === "true",
};

With it on, the server:

  • adds X-Robots-Tag: noindex, nofollow to every response (pages, API routes and files),
  • renders <meta name="robots" content="noindex, nofollow"> on every page, replacing any meta.robots,
  • answers /robots.txt with Disallow: / for all crawlers, ignoring public/robots.txt.

Nothing is written to disk, so the same build can be deployed to production with the option off. Responses returned directly by middleware are left untouched.


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 .bunext/public/cache/. Each cached page produces two files:

File Contents
<key>.res.html The cached HTML response body
<key>.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:

// src/pages/products.tsx
import type { BunextRouteConfig } from "@moduletrace/bunext/types";

export const config: BunextRouteConfig = {
    cachePage: true,
    cacheExpiry: 300, // seconds — optional, overrides the global default
};

export default function ProductsPage() {
    return <h1>Products</h1>;
}

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:

// src/pages/products.server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";

const server: BunextPageServerFn = async (ctx) => {
    const data = await fetchProducts();

    return {
        props: { data },
        cachePage: true,
        cacheExpiry: 600, // 10 minutes
    };
};

export default server;
// src/pages/products.tsx
export default function ProductsPage({ props }: any) {
    return (
        <ul>
            {props.data.map((p: any) => (
                <li key={p.id}>{p.name}</li>
            ))}
        </ul>
    );
}

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 .bunext/public/) 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 .bunext/public/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.
  • No key collision. Cache keys are generated via encodeURIComponent() on the URL path. /foo/bar encodes to %2Ffoo%2Fbar and /foo-bar to %2Ffoo-bar — distinct filenames with no collision risk.

Configuration

Create a bunext.config.ts file in your project root to configure Bunext:

// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/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",
    },
    public_envs: {
        BUNEXT_PUBLIC_APP_NAME: "My App",
    },
    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
public_envs Record<string, string> — Public env vars exposed to the client via window.process.env (see Environment Variables)
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
websocket WebSocketHandler<any> — Bun WebSocket handler — see WebSocket
serverOptions ServeOptions — Extra options passed to Bun.serve() (excluding fetch) — see Server Options
sitemap BunextSitemapConfig — Options for bunext sitemap — see Sitemap
robots BunextRobotsConfig | false — robots.txt options for bunext sitemap, or false to skip it — see robots.txt
disable_seo boolean false Keep the whole site out of search engines (e.g. preview deployments) — see Disabling SEO

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.
// bunext.config.ts
import type {
    BunextConfig,
    BunextConfigMiddlewareParams,
} from "@moduletrace/bunext/types";

const config: BunextConfig = {
    middleware: async ({ req, url }) => {
        // 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:

middleware: async ({ req, url }) => {
    // Block a specific path
    if (url.pathname === "/maintenance") {
        return new Response("Down for maintenance", { status: 503 });
    }

    // For API routes, return undefined to continue — the handler controls its own Response directly
},

WebSocket

Add a websocket field to bunext.config.ts to handle WebSocket connections. The value is passed directly to Bun.serve() as the websocket option and accepts the full WebSocketHandler interface.

Define the handler in its own file and import it into the config:

// websocket.ts
import type { WebSocketHandler } from "bun";

export const BunextWebsocket: WebSocketHandler<any> = {
    message(ws, message) {
        console.log(`WS Message => ${message}`);
    },
};
// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/types";
import { BunextWebsocket } from "./websocket";

const config: BunextConfig = {
    websocket: BunextWebsocket,
};

export default config;

Server Options

Pass additional options to the underlying Bun.serve() call via serverOptions. All standard ServeOptions fields are accepted except fetch, which Bunext manages internally.

// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/types";

const config: BunextConfig = {
    serverOptions: {
        tls: {
            cert: Bun.file("./certs/cert.pem"),
            key: Bun.file("./certs/key.pem"),
        },
        maxRequestBodySize: 64 * 1024 * 1024, // 64 MB
        error(err) {
            console.error("Server error:", err);
            return new Response("Internal Server Error", { status: 500 });
        },
    },
};

export default config;

Custom Server

For full control over the Bun.serve() instance — custom WebSocket upgrade logic, multi-protocol handling, or integrating Bunext alongside other handlers — you can skip bunext dev / bunext start and run your own server using Bunext's exported primitives.

// server.ts
import bunext from "@moduletrace/bunext";

const development = process.env.NODE_ENV === "development";
const port = process.env.PORT || 3700;

await bunext.bunextInit();

const server = Bun.serve({
    routes: {
        "/*": {
            async GET(req) {
                return await bunext.bunextRequestHandler({ req });
            },
        },
    },
    development,
    port,
});

bunext.bunextLog.info(`Server running on http://localhost:${server.port} ...`);
Export Type Description
bunextInit() () => Promise<void> Initializes config, router, and bundler. Must be called before handling requests.
bunextRequestHandler({ req }) (params: { req: Request }) => Promise<Response> The main Bunext request dispatcher — middleware, routing, SSR, static files. Only req is needed; the server instance is managed internally.
bunextLog Logger Framework logger (info, error, success, server, watch).

Run the custom server directly with Bun:

bun run server.ts

Note: When using a custom server, HMR and file watching are still driven by bunextInit(). Pass development: true in your Bun.serve() call to enable them.


Environment Variables

Variable Description
PORT Override the server port (takes precedence over config)
BUNEXT_PUBLIC_* Any env var prefixed with BUNEXT_PUBLIC_ is exposed to the client via window.process.env

Public Environment Variables

Variables prefixed with BUNEXT_PUBLIC_ are automatically injected into every page as window.process.env. You can also define public envs in config via public_envs (config values override env vars of the same name):

# .env
BUNEXT_PUBLIC_API_URL=https://api.example.com
// bunext.config.ts
const config: BunextConfig = {
    public_envs: {
        BUNEXT_PUBLIC_APP_NAME: "My App",
    },
};
// Client component — available after hydration
const apiUrl = window.process.env.BUNEXT_PUBLIC_API_URL;

window.process.env always includes NODE_ENV ("development" or "production").


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. Creates an ESBuild context and performs the initial build. File-change rebuilds are triggered manually by the FS watcher.
  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 with minification enabled.
  3. Writes all bundled artifacts to .bunext/public/pages/ plus the artifact maps .bunext/public/pages/map.json and .bunext/public/pages/ssr-bundler-ctx-map.json.
  4. Artifact maps record paths relative to the project root so they resolve at request time regardless of where the build ran.
  5. Exits.

Production Server

Running bunext start:

  1. Reads .bunext/public/pages/map.json to load the pre-built artifact map.
  2. Starts Bun.serve() without any bundler or file watcher.

Bundler

The bundler uses ESBuild with a virtual namespace plugin that generates in-memory hydration entry points for each page — no temporary files are written to disk. Each virtual 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. Tailwind CSS is processed via a dedicated ESBuild plugin.

In development, an esbuild.context() is created once and rebuilt incrementally whenever the FS watcher detects a file change. In production, a single esbuild.build() call runs with minification enabled.

React is loaded externally — react, react-dom, react-dom/client, and react/jsx-runtime are all marked as external in the ESBuild config. The correct React version is resolved from the framework's own node_modules at startup and injected into every HTML page via a <script type="importmap"> pointing at esm.sh. This guarantees a single shared React instance across all page bundles and HMR updates regardless of project size.

After each build, ESBuild's metafile is used to map each output file back to its source page, producing a BundlerCTXMap[]. This map is stored in global.BUNDLER_CTX_MAP and written to .bunext/public/pages/map.json.

Output files are named [hash].[ext] 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 update event through the stream containing the new artifact metadata. The client then performs a true in-place HMR update — no full page reload:

  1. If the page has a CSS bundle, the old <link rel="stylesheet"> is replaced with a new one pointing to the updated CSS file (cache-busted with ?t=<timestamp>).
  2. The existing client hydration <script> (identified by id="bunext-client-hydration-script") is removed from the DOM.
  3. A new <script type="module"> is injected pointing to the freshly rebuilt JS bundle (also cache-busted).
  4. The new bundle calls window.__BUNEXT_RERENDER__(NewComponent) if the root is already mounted, otherwise falls back to a fresh hydrateRoot.

The endpoint /__bunext_client_hmr__?href=<page-url> handles on-demand HMR bundle generation: it re-bundles the target page's component on every request so the freshly injected script always reflects the latest source.

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)
  │
  ├── GET /__bunext_client_hmr__?href=  → On-demand HMR bundle for the given page URL (dev only)
  │
  ├── /api/*              → API route handler
  │     Matches src/pages/api/<path>.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 .bunext/public/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. Import companion server module (<page>.server.ts/tsx) if it exists; run its exported function 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 <script>, CSS <link>
        8. If module.config.cachePage or serverRes.cachePage → write to cache
        9. Return HTML response
        On error → render 404 or 500 error page

Server-rendered HTML includes:

  • window.__PAGE_PROPS__ — the serialized server function return value, read by hydrateRoot on the client.
  • A <script type="importmap"> mapping React package specifiers to the esm.sh CDN (uses the ?dev build in development).
  • A <script type="module" async> tag pointing to the page's bundled client script.
  • A <link rel="stylesheet"> tag if the bundler emitted a CSS file for the page.
  • In development: the HMR client script.

Dependencies

Dependencies

ID Version
@tailwindcss/postcss ^4.3.3
@types/bun 1.3.14
@types/node ^24.10.0
@types/react ^19.3.0
@types/react-dom ^19.3.0
bun-plugin-tailwind ^0.1.2
chalk ^5.6.2
chokidar ^5.0.0
commander ^14.0.2
esbuild ^0.28.2
lightningcss-wasm ^1.32.0
lodash ^4.18.1
ora ^9.0.0
postcss ^8.5.30
react ^19.3.0
react-dom ^19.3.0
tailwindcss ^4.3.3
typescript ^5.0.0
uuid ^14.0.2

Development Dependencies

ID Version
@testing-library/dom ^10.4.1
@types/chokidar ^2.1.7
@types/lodash ^4.17.26
@types/uuid ^11.0.0
happy-dom ^20.14.6
Details
npm
2026-10-16 17:42:36 +02:00
4
83 KiB
Assets (1)
Versions (119) View all
1.3.4 2026-10-17
1.3.3 2026-10-16
1.3.2 2026-10-16
1.3.1 2026-10-16
1.3.0 2026-10-16