Skip to content

Repository files navigation

Brandy

Brandy is an experimental HTML-over-the-wire framework with Next.js-style file-system routing and persistent nested layouts, but no React or RSC. Bun powers development and compilation; production output can target Bun, Cloudflare Workers, or Vercel Functions. Brandy renders HTML on the server and sends only the route-tree fragment that diverged during soft navigation.

Run the example

bun install
bun test
bun run dev

Open http://localhost:3000. Links and forms work as ordinary full-page requests without JavaScript. With JavaScript, Brandy derives the swap target from the current and destination routes. Back and forward navigation re-fetches and re-diffs the popped URL; it does not restore DOM snapshots.

Commands

Brandy owns the server bootstrap. Applications do not need a server.ts:

{
  "scripts": {
    "dev": "brandy dev",
    "build": "brandy build",
    "start": "brandy start"
  }
}
  • brandy dev watches routes, configuration, public files, and styles. Page and nested-layout changes refresh the affected boundary while shared layout state remains mounted.
  • brandy build compiles the route manifest and emits the selected adapter's handler and static assets.
  • brandy start runs Bun standalone output. Cloudflare and Vercel output is deployed with their platform CLIs.

The defaults are app/, public/, an optional root styles.css, .brandy/, and port 3000. CLI --host and --port values override HOST/PORT, which override config and defaults.

An optional brandy.config.ts changes conventions, selects a deployment adapter, or adds portable request routes without becoming a bootstrap file:

import { defineConfig } from "brandy/config"

export default defineConfig({
  alpine: true,
  // Optional exact origins allowed to submit server actions in addition to this deployment's origin.
  trustedOrigins: ["https://admin.example.com"],
  setup(app) {
    app.get("/api/health", () => ({ ok: true }))
  },
})

Deployment adapters

Bun remains the build tool for every target. Build-time route walking, Tailwind, subprocesses, and file copying are not included in the generated request handler.

import { cloudflare, vercel } from "brandy/adapters"
import { defineConfig } from "brandy/config"

export default defineConfig({
  adapter: cloudflare(),
  // adapter: vercel({ runtime: "node" }),
  // adapter: vercel({ runtime: "edge" }),
})
  • Bun is the default and emits .brandy/server.js; run it with brandy start.
  • Cloudflare emits .brandy/cloudflare/worker.js, static assets, and wrangler.jsonc. Deploy with wrangler deploy --config .brandy/cloudflare/wrangler.jsonc.
  • Vercel emits Build Output API v3 under .vercel/output. Deploy with vercel deploy --prebuilt.
  • Cloudflare and Vercel Edge builds reject Bun globals and filesystem, subprocess, socket, and other unsupported Node imports with the originating module path.
  • Serverless adapters support immutable prerender = true routes. They reject revalidate, dynamic prerendering without parameter enumeration, and action-driven mutation of immutable pages until Brandy has a durable cache API.

The example's /server route intentionally demonstrates Bun and Node filesystem/process APIs, so that route is not edge-compatible. Edge applications should keep loader dependencies Web API compatible.

Server actions reject POSTs whose Origin (or, when absent, Referer) does not match the request origin. They also reject requests without either header. trustedOrigins is empty by default; add only exact HTTP(S) origins that should be allowed to submit actions.

When styles.css exists, Brandy compiles it with Tailwind and injects the stylesheet automatically. Production builds fingerprint Brandy's runtime and stylesheet URLs and serve those framework assets with immutable cache headers; compression such as Brotli or gzip should be enabled at your server or CDN layer. Files under public/ are served from root-relative URLs.

File conventions

app/
  layout.tsx       # persistent root shell
  page.tsx         # /
  error.tsx        # nearest error boundary
  not-found.tsx    # nearest loader-level 404 boundary
  dashboard/
    actions.ts     # colocated server mutations
    layout.tsx
    page.tsx
    analytics/
      loading.tsx  # streaming skeleton for this segment
      page.tsx
    users/
      [id]/
        page.tsx   # /dashboard/users/:id
      (.)[id]/
        page.tsx   # modal rendering of /dashboard/users/:id on soft navigation
  404/
    page.tsx       # fallback for unmatched URLs

loading.tsx declares a streaming boundary: the skeleton flushes immediately and the resolved content streams into the same slot when its loaders settle. Deferred content is applied by JavaScript, so a no-JS client sees only the skeleton — do not add loading.tsx to routes that must work without JavaScript.

(.)name, (..)name, and (...)name directories intercept soft navigation to an existing standalone route and render its alternate page into a reserved modal outlet; a hard load of the same URL renders the standalone page. The dot count is relative to the marker's own directory, matching Next's semantics.

Route groups — (group) directories — are supported: the directory name is stripped from the URL so app/(marketing)/about/page.tsx produces the route /about. The group name must contain only letters, digits, underscores, hyphens, or spaces; any other parenthesised directory name that is not a valid intercept marker throws a clear error at startup.

Next.js conventions Brandy does not yet implement: catch-all segments ([...slug]) and generateStaticParams. A [...slug] directory is currently treated as a literal dynamic segment named ...slug, so catch-all routes silently match only one level; generateStaticParams has no equivalent export and will be ignored.

Server JSX is rendered to strings by @elysiajs/html; it is never hydrated. Brandy automatically injects its client runtime, so the root layout needs no script setup:

import { Html } from "@elysiajs/html"

export default function Layout({ children }: { children: JSX.Element }) {
  return <html><body>{children}</body></html>
}

Loaders, params, and metadata

Each layout and page can export a load function. On partial navigation, loaders run in parallel from the route divergence down. params, request, and url are available to loaders and templates.

import { Html } from "@elysiajs/html"
import type { Metadata, RenderContext } from "brandy"

export async function load({ params }: { params: Record<string, string> }) {
  return db.users.find(params.id)
}

type Data = Awaited<ReturnType<typeof load>>

export function metadata({ data }: RenderContext<Data>): Metadata {
  return { title: `${data.name} · Users`, meta: { description: data.bio } }
}

export default function Page({ data }: RenderContext<Data>) {
  return <h1>{data.name}</h1>
}

Metadata is inserted on cold loads and reconciled out-of-band after partial navigation.

Shared data functions can opt into request-scoped memoization. Calls with the same arguments share pending and completed work across parallel layout and page loaders during one render, but are never reused by another request. Object arguments compare by reference.

import { memoizeLoader } from "brandy"

export const getUser = memoizeLoader(async (id: string) => db.users.find(id))

export function load({ params }: { params: Record<string, string> }) {
  return getUser(params.id)
}

Redirects and authentication

Call redirect(url) inside any load function to send the browser to another URL. The second argument is the HTTP status code (default 302):

import { redirect } from "brandy"
import type { RequestContext } from "brandy"

export async function load({ request }: RequestContext) {
  const session = await getSession(request)
  if (!session) redirect("/login")
  return session.user
}

On cold (full-page) loads the server responds with the redirect status and a Location header, so the browser follows it before any HTML is rendered. On client-side soft-navigation the runtime receives a x-brandy-redirect response header and calls location.assign(), replacing the current page at the destination URL.

redirect() bypasses error and not-found boundaries — it always propagates out of loaders without invoking any error.tsx or not-found.tsx.

Auth pattern for protected areas. The idiomatic way to protect a subtree is a redirect in the nearest layout loader. A dashboard/layout.tsx that redirects to /login when unauthenticated covers every route under dashboard/ without repeating the guard in each page:

// app/dashboard/layout.tsx
import { redirect } from "brandy"
import type { LayoutRenderContext, RequestContext } from "brandy"

export async function load({ request }: RequestContext) {
  const session = await getSession(request)
  if (!session) redirect("/login")
  return session.user
}

export default function DashboardLayout({ children, data }: LayoutRenderContext<User>) {
  return (
    <div>
      <nav>Welcome, {data.name}</nav>
      {children}
    </div>
  )
}

Brandy deliberately has no middleware layer. Loaders already have access to request, params, and url, and a layout loader covers every route in its subtree, which is the same scope middleware would. Keeping auth in loaders means the same TypeScript, the same error boundaries, and the same data-fetching primitives apply everywhere — no separate execution context to reason about.

Prerendering

A page can export prerender = true to cache its rendered output until an action revalidates it, or revalidate = 60 to recompute it at most every 60 seconds. Both complete documents and soft-navigation fragments are cached, and statically-patterned routes are warmed at build time.

export const revalidate = 60

export async function load() {
  return db.stats.summary()
}

The cache stores everything above the page too: the route's ancestor layouts render once and are served to every visitor. No loader on a cached route — page or layout — may read per-user request data such as cookies or authorization headers. Brandy does not yet detect this, so a personalized cache entry would be served to other users. Caching and loading.tsx streaming are mutually exclusive on one route.

Navigation and prefetching

The client runtime intercepts same-origin links and forms, fetches the diverged fragment, and swaps it at the boundary the server names in its response headers. Hovering or focusing a link for a moment prefetches its fragment; a prefetched entry is used only if the page it was requested from is still the current page.

Three boolean attributes opt out per element. Their presence enables the behavior; no value is required.

Attribute Allowed elements Behavior
data-brandy-reload <a>, <form> Bypass the client runtime and let the browser perform a full-page navigation or native form submission. Links with this attribute are not prefetched.
data-brandy-no-prefetch <a> Disable hover and focus prefetching. Clicking the link still uses normal soft navigation and remains eligible for an intercepting route.
data-brandy-no-intercept <a> Use soft navigation to the standalone route even when an intercepting (modal) route is available. These links are not prefetched because the normal prefetched response could target the intercepting route.

When attributes are combined, data-brandy-reload takes precedence because the client runtime does not handle the link at all. Combining data-brandy-no-intercept with data-brandy-no-prefetch is allowed but redundant: data-brandy-no-intercept already disables prefetching. data-brandy-no-prefetch and data-brandy-no-intercept have no effect on forms.

Actions

Actions live in actions.ts. defineAction preserves a callable server function while allowing the same reference to render as a generated form URL. Returning revalidate(path) runs that route's loader and uses the normal fragment pipeline.

// actions.ts
import { defineAction, revalidate } from "brandy"

export const addToCart = defineAction(async (form) => {
  await db.cart.add(form.get("id"))
  return revalidate("/cart")
})

// page.tsx
<form method="post" action={addToCart}><button>Add</button></form>

Without JavaScript, successful actions redirect with 303 to the revalidated route.

Errors and 404s

  • error.tsx receives { error, dev, params, request, url } and catches loader or render failures at the nearest segment.
  • not-found.tsx handles notFound() outcomes from loaders.
  • app/404/page.tsx handles unmatched URLs. If omitted, Brandy emits a minimal built-in 404.

The original error is available to the boundary for server-side logging, so treat its message and stack as sensitive. Only render diagnostic details when dev is true; it is false by default and enabled by brandy dev:

import type { ErrorContext } from "brandy"

export default function ErrorPage({ error, dev }: ErrorContext) {
  const message = dev && error instanceof Error
    ? error.message
    : "Something went wrong."
  return <p role="alert">{message}</p>
}

Do not render error.message, error.stack, or String(error) unconditionally. Production builds explicitly disable development error details.

Client state boundary

Navigation and server data are Brandy's responsibility. Client state is Alpine's responsibility. Wrap interactive markup in an <Island>: Brandy serves Alpine as a separate chunk and fetches it lazily, only when a rendered page or fragment actually contains an island, so fully static routes never download Alpine. There is still no client entrypoint, asset route, or script tag to write.

import { Island } from "brandy"

<Island tag="section">
  <div x-data="{ open: false }">
    <button x-on:click="open = !open">Toggle</button>
    <p x-show="open" x-cloak="">Client-side state</p>
  </div>
</Island>

An Island renders a <div> boundary by default. Pass tag to use another HTML element when the wrapper needs different semantics or layout behavior. When Alpine is enabled, Brandy automatically injects [x-cloak]{display:none!important} so Alpine's x-cloak directive prevents pre-initialization flashes without application CSS.

Alpine attributes outside an <Island> render as inert HTML and never become interactive; brandy dev logs a console warning when it finds one. Island state is client-side only, so a fragment swap that replaces an island resets its state.

Use Alpine's long-form directives in TSX. Shorthands such as @click and :class are not valid TSX attribute syntax.

TypeScript also cannot parse dots in TSX attribute names. Use a typed spread when an Alpine modifier is needed:

<form {...({ "x-on:submit.prevent": "save()" } satisfies JSX.BrandyAlpineAttributes)}>
  <button>Save</button>
</form>

Alpine observes fragment swaps automatically. After each swap, Brandy also calls window.Brandy.reinit(root) when provided, preserving the reactivity-agnostic seam for another client library. Set alpine: false to keep Brandy navigation without bundling Alpine:

const app = await createBrandy({ appDir: "./app", alpine: false })
window.Brandy = { reinit(root) { /* initialize another client-state library */ } }

About

a web framework for apps where client interactivity is a second thought

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages