Skip to content
dihlabs.lol

Building with optz

Framework reference

Everything optz does, with examples: routes, render modes, islands, Server Actions, streaming, ISR, API routes, i18n, images, fonts and deploy targets.

On this page

A small, fast React framework for Bun. File routes, nested layouts, streaming, Server Actions, typed API routes, middleware, incremental static regeneration, i18n, image and font optimization, and a response cache. Pages are HTML by default: only the components you mark as islands ship JavaScript. Runs on Bun, as a single executable, or as an edge worker.

bun framework/src/cli.ts create apps/my-app   # a starter that shows the basics
cd apps/my-app && bun install
bun run dev      # http://127.0.0.1:3001, updates in place on save (Fast Refresh for islands)
bun run check    # generate route types + type-check everything
bun run build    # dist/   (--compile: also a single executable; --target edge: also a worker)
bun run start    # production server (PORT/HOST env vars override the config)
optz nginx       # nginx config generated from optz.config.ts
optz export      # static site only, for any static host

apps/features/ uses every feature below in one app; node apps/features/e2e.cjs checks them in a browser. Compared with Next.js, feature by feature and measured: docs/OPTZ-VS-NEXTJS.md.

Project layout

optz.config.ts          one typed config: client mode, features, cache, i18n, images, fonts, proxies
app/
  routes/               pages, one file per URL
  api/                  API routes, under /api
  components/           components; *.island.tsx ones hydrate in the browser
  lib/                  shared code ($lib/...)
  middleware.ts         runs before every page and API request
  sitemap.ts robots.ts manifest.ts   built into /sitemap.xml, /robots.txt, /manifest.webmanifest
  styles.css            Tailwind CSS v4 (`@import "tailwindcss";`)
public/                 copied as-is
.optz/types.d.ts        generated: your routes and APIs as types

Pages (app/routes)

fileURL
index.tsx/
blog/[slug].tsx/blog/:slug
docs/[...rest].tsx/docs/* (one or more segments)
shop/[[...path]].tsx/shop and /shop/* (zero or more)
(group)/pricing.tsx/pricing (brackets group files without a URL segment)
_layout.tsx, _error.tsx, _loading.tsx, _404.tsxlayout, error page, streaming fallback, not-found page
about.og.tsxthe Open Graph image of about.tsx
import { Link, notFound, redirect, type LoaderContext, type PageProps } from "optz";

export const config = { render: "static" } as const;   // "static" (default) | "client" | "dynamic"
export const paths = () => [{ slug: "hello" }];         // static pages with params: what to build
export const loader = ({ params }: LoaderContext<{ slug: string }>) => getPost(params.slug) ?? notFound();
export const head = (post: Post) => ({ title: post.title, description: post.summary, openGraph: { type: "article" } });

export default function Page({ data, params }: PageProps<Post, "/blog/[slug]">) {
  return <Link href="/blog">Back</Link>;   // href is checked against your routes
}
renderwhenper-request cost
staticcontent known at build timea memory lookup
clientdata comes from an API in the browser (feeds, dashboards)a memory lookup
dynamicneeds the request (cookies, headers) or fresh server dataa React render, streamed (or cached with cache)

loader, paths and cache run on the server only and never reach the browser.

Streaming and async components

Dynamic pages stream: the layout and each <Suspense> fallback go out at once, and async components fill in as they finish. _loading.tsx wraps the page in a boundary. Static pages wait for every boundary at build time and contain no streaming scripts.

export const config = { render: "dynamic" } as const;
async function Comments({ id }: { id: string }) {
  const comments = await db.comments(id);
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
export default function Post() {
  return <Suspense fallback={<p>Loading comments</p>}><Comments id="1" /></Suspense>;
}

React's streaming needs a few inline scripts. optz gives each streamed response a nonce and adds it to the Content-Security-Policy your middleware set, so a strict CSP (no 'unsafe-inline') keeps working.

Incremental static regeneration

export const revalidate = 60;          // rebuild in the background at most once a minute
export const tags = ["posts"];         // revalidateTag("posts") rebuilds it now
export const dynamicParams = true;     // render ids paths() didn't list on first request, then keep them

The stale copy is served while one background render replaces it. revalidatePath("/blog") and revalidateTag("posts") (from optz/server) work from API routes and Server Actions, e.g. a CMS webhook.

Parallel and intercepting routes

A folder @name next to a _layout.tsx is a slot: the layout gets it as a prop, rendered from the file in @name that matches the URL (or @name/default.tsx). A folder (.)photo, (..)photo or (...)photo intercepts that URL during in-app navigation from its folder, e.g. a photo that opens as a modal over the gallery but is a full page on reload:

gallery/_layout.tsx                 export default ({ children, modal }) => <>{children}{modal}</>
gallery/@modal/default.tsx          null
gallery/@modal/(.)photo/[id].tsx    the modal
gallery/photo/[id].tsx              the full page

Islands: the payload optimization

In client.mode: "islands" (the default), a page is just HTML. A component in a file named *.island.tsx renders on the server and only it hydrates in the browser. A page with no islands ships no React at all.

// app/components/Counter.island.tsx
export const hydrate = "visible";   // "load" (default) | "idle" | "visible"
export default function Counter({ start }: { start: number }) { ... }
  • Props must be JSON. Islands inside islands render inline; the outer one owns them.
  • useParams(), useRouter(), useLocale() and <Link> work inside islands.
  • client.navigation swaps page HTML on link clicks (~2 KB) with prefetch on hover, inside a view transition where the browser has them.
  • client.speculation (on): in browsers with speculation rules (Chromium), pages send a Speculation-Rules header and the browser prerenders a link while it's hovered, so the click is a real navigation that shows instantly, with a CSS cross-document view transition. Other browsers use the HTML swap. Apps with parallel or intercepting routes keep the swap, since a real navigation can't keep a slot or open an intercepted route in place.
  • useRouter().refresh() (and any Server Action that revalidates) re-fetches the page and keeps every island whose props didn't change mounted, state and all.
  • client.runtime: "preact" hydrates islands with Preact (~4 KB) instead of React (~60 KB).
pageSPA + Reactislands + Reactislands + Preact
home (2 islands)66.6 / 208 KB64.0 / 210 KB14.5 / 18 KB
blog post (no islands)63.1 / 208 KB3.3 / 5.5 KB2.9 / 5.6 KB

(JS per page load, compressed / parsed.)

Server Actions

Modules that start with "use server" export functions the browser can call. Islands import them like any module; types come from the real code.

// app/actions/notes.ts
"use server";
import { ActionError, revalidatePath } from "optz/server";
export async function addNote(form: FormData): Promise<void> {
  const text = String(form.get("text") ?? "");
  if (!text) throw new ActionError("Write something first.");   // this message reaches the browser
  await db.notes.add(text);
  revalidatePath("/notes");                                       // the page updates in place
}
<form action={addNote}>...</form>                       // works without JavaScript too
<form action={rename.bind(null, note.id)}>...</form>     // bound arguments, also without JS
const n = await countNotes();                           // from an island: a typed call

Calls from islands POST JSON (or the FormData) to /_optz/action/<id>/<name>; plain form posts must come from the same site (CSRF check). Errors other than ActionError are reported as "Something went wrong" in production.

Server functions (optz/server)

cookies() (read, set, delete), headers(), after(fn) (run after the response is sent), draftMode(), revalidatePath(), revalidateTag(), locale(). They work in API routes, Server Actions and dynamic pages.

Draft mode: an API route your CMS calls turns it on with draftMode().enable(); while the signed cookie is set, static pages render per request and ctx.draft is true in loaders.

API routes (app/api), typed end to end

// app/api/posts/[id].ts  ->  /api/posts/:id
import { handler, json, type ApiContext, type HandlerContext, type RouteCache } from "optz";

export const cache: RouteCache = { maxAge: 30, staleWhileRevalidate: 300, tags: ["posts"] };
export const GET = ({ params }: ApiContext<{ id: string }>) => json({ id: params.id, likes: 3 });
export const POST = handler(async ({ params, body }: HandlerContext<{ id: string }, { text: string }>) =>
  json({ saved: (await body()).text }, { status: 201 }));
import { api } from "optz";
const post = await api("/api/posts/[id]", { params: { id: "7" } });   // post.likes: number
await api("/api/nope");   // type error: no such route

export const prerender = true runs GET at build time and serves the result from memory.

Metadata, images, fonts, scripts

  • Head: title, description, canonical, robots, keywords, openGraph, twitter, alternates; relative URLs become absolute with site.url.
  • Open Graph images: page.og.tsx next to page.tsx returns JSX (inline styles, flexbox), rendered to a 1200×630 PNG at build time with satori and added to og:image automatically.
  • <Image> (optz/image): srcset with widths from images.sizes, AVIF or WebP by the browser's Accept, width/height reserved, lazy below the fold, priority for the hero. Resized on first request with sharp, then kept in memory and on disk. Only local files and images.remoteHosts are served.
  • Fonts: list Google Fonts css2 URLs in fonts; at build time they're downloaded into /assets, preloaded, and get a size-matched local fallback ("Inter Fallback") computed from the font file, so text doesn't shift when they load. No request to Google at runtime. Downloads are cached in ~/.cache/optz (OPTZ_CACHE_DIR), so rebuilds don't fetch them again and a build still works offline from a stale copy.
  • <Script> (optz/script): beforeInteractive, afterInteractive (default) or lazyOnload; each src loads once, across navigations too.

i18n

i18n: { locales: ["en", "fr"], defaultLocale: "en" }

/fr/blog/x renders app/routes/blog/[slug].tsx with locale: "fr" (in PageProps, loaders, head(), useLocale()). The default locale has no prefix (prefixDefault: true adds one). / redirects by Accept-Language or the optz-locale cookie. <Link> keeps the current locale, <Link locale="fr"> switches. Static pages are built once per locale, with <html lang> and hreflang alternates.

Middleware

// app/middleware.ts: one function or an array, run in order
export default defineMiddleware(async (ctx, next) => {
  ctx.locals.user = await userFrom(ctx.request);   // seen by loaders and API routes
  const res = await next();
  res.headers.set("X-Frame-Options", "DENY");
  return res;
});

Deploy targets

commandwhat you get
Bun serveroptz build && optz startthe fastest option; optz nginx for the proxy in front
Single executableoptz build --compiledist/server: one file with the runtime, code, pages and assets (~105 MB)
Edge workeroptz build --target edgedist/edge/worker.js + wrangler.toml: Cloudflare Workers, Deno Deploy, Vercel Edge
Static siteoptz exportdist/static for any static host (no server features)

The request handler is the same web-standard code everywhere; apps/features/e2e.cjs passes on all three servers. Image optimization needs sharp: the executable uses it when it can find it, edge workers serve originals. Open Graph images for static pages are prebuilt everywhere.

Config

Every option is typed and documented on hover in optz.config.ts. The ones that change cost:

optiondefaulteffect
client.mode"islands""spa" hydrates whole pages (more JS)
client.runtime"react""preact": ~60 KB less JS on pages with islands
client.navigation / prefetchonHTML-swap navigation / prefetch on hover
client.speculationonprerender on hover via speculation rules where supported
features.dynamiconoff: per-request rendering is a build error and the renderer never loads (~13 MB less memory)
features.streamingonoff: dynamic pages are sent whole
features.actions, images, cache, middleware, apionskip loading them entirely
features.dynamicCompression, etagongzip and 304s for dynamic responses
features.requestLogoffone log line per request
cache.maxEntries / maxBytes / default500 / 32 MB / noneresponse cache bounds, and a default for routes without cache
server.idleTimeout / maxRequestBodySize30 s / 1 MBconnection and body limits
server.cdnMaxAge0seconds a CDN may cache HTML pages (s-maxage, then stale-while-revalidate); browsers still revalidate
proxy[prefix].cacheSeconds0nginx micro-cache for a proxied API (via optz nginx)

Dev server

optz dev rebuilds on save and updates open pages without reloading them: changed stylesheets are swapped, changed islands are hot-swapped with React Fast Refresh (their state survives), and the page's server-rendered HTML is replaced in place around them. In dev, islands always use React (for Fast Refresh), whatever client.runtime says.

Measured (one core)

Static pages ~55k req/s, prerendered API ~55k req/s, memory ~35 MB idle and ~50 MB under load. Against Next.js 16 on the same app: ~100× the requests per second, a sixth of the memory, 2.9 vs 145 KB of JS on a blog post (docs/OPTZ-VS-NEXTJS.md). Against nginx serving the same files: the same speed (docs/NATIVE.md).

Tests

bun test in framework/: routing (optional catch-alls, slots, intercepts), the cache, islands, actions, fonts, SEO files, and the whole apps/features app in-process (streaming with nonces, actions and CSRF, ISR, dynamicParams, intercepts, draft mode, i18n). Browser checks: node apps/features/e2e.cjs (22) and node apps/features/hmr.cjs (dev server, 6).