zigapagos

Astro's islands. A native core. No Node.

Zigapagos renders your pages with a single fast binary and adds interactivity one component at a time — TSX islands, server-rendered at build time, hydrated in the browser only where you ask for it.

Get started See it running

That button is a real island on this page. It was server-rendered into the HTML you received, then hydrated when it scrolled into view. View source — there is no framework runtime wrapping the rest of the page.

Zero JS by default

A page with no islands ships no framework JavaScript.

0 KB of framework runtime on a page with no islands — every docs page on this site
1 shared runtime, however many islands a page has
0 Node, Vite, webpack or bundler config in your project

Two honest exceptions, both of which devtools will show you. This page is a demo, so it ships the shared runtime plus the islands you can click. And every page — including the docs — carries about 1 KB of inline script for the theme toggle (closer to 550 bytes over the wire once gzipped), which is exactly why the toggle is inline script and not an island: making it a component would have put a framework runtime on every page of a generator whose pitch is that most pages need none. Open the network tab on any docs page and that is all you will find.

Islands in 30 seconds

Write a component. Say when it should wake up.

import { useState } from "@z/runtime";

export interface Props { start?: number }

export default function Counter({ start = 0 }: Props) {
  const [n, setN] = useState(start);
  return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;
}

An island is a TSX component with a client: directive. Zigapagos renders it to HTML at build time so the page is complete without JavaScript, then ships just that component's code plus one shared runtime.

Props are typechecked at build time against the component's exported Props type. A misspelled prop fails the build rather than rendering undefined in production.

Islands reference →

One attribute picks the moment

Say when an island should wake up. Nothing wakes up by default.

client:load

Hydrates immediately, as soon as the runtime script runs.

client:idle

Waits for requestIdleCallback, so it never competes with the page's first paint.

client:visible

Hydrates when an IntersectionObserver reports the island entered the viewport.

client:media

Hydrates only while a matchMedia query matches — a sidebar that is never interactive on mobile ships no JS there.

client:only

Hydrates immediately, like load, but is never server-rendered — the placeholder starts empty and the component exists only in the browser.

no directive

Server-rendered HTML, no JavaScript, no runtime cost. The default for every island until you add one.

See all five directives hydrate on one page →

Native SPAs

When a site should be an application, it is still one file

Export a route table from a .spa.tsx and Zigapagos prerenders a real HTML shell for every static route, then hands over to a client-side router. Nested layouts, route guards, code-split routes and declarative redirects are part of the route table, not a library you add.

The shells are static files, so a guarded route still deploys to a CDN — the guard runs in the browser after the shell is served.

Open the demo app

export const spa = { base: "/app" };

export const routes = [
  { path: "/", component: Home },
  { path: "/guides", component: Layout, children: [
      { path: "/:slug", component: Guide },
  ]},
  { path: "/account", component: Account,
    guard: requireSession },
];

Coming from Astro

A worklist, not a conversion

zigapagos migrate <astro-dir> scans an Astro project and writes MIGRATION.md: every source file mapped to its Zigapagos target, every component that needs a client: directive flagged as a real island, and the standard procedure for each. It does not rewrite your site — it tells you exactly what to rewrite.

The opt-in --scaffold <dir> pass goes one step further for islands specifically: it ports each component's imports from React to @z/runtime and carries over its Props type verbatim, so you are editing a real starting point instead of an empty file. It never overwrites a file you already migrated by hand.

Try the migrate demo →

The full Astro → Zigapagos mapping →

One binary and Bun

The whole build, and nothing you have to configure

content/*.smd    ──►  native core  ──┐
                    (SuperMD/SuperHTML)  │
                                         ├──►  static site
components/*.island.tsx ──►  Bun SSR  ───┤     + import map
                        └──►  bundle  ───┘     + one runtime

Content and templates are rendered by a native binary. Islands are server-rendered and bundled by Bun. That is the entire toolchain: no Node install, no bundler configuration, no plugin ecosystem to keep current.

How the build works →

Deploy anywhere static

The output is static files and config files, not a running process

Bun runs only at build time — no Bun process in production, no Node runtime to keep patched. What lands on disk is HTML and JS, plus route config for exactly one server: whichever deploy_target the site declares (zigbase, nginx, or apache), written inside each SPA's own output directory.

  • ZigBase (this site's target, and the default) — a .spa marker file the stock zigbase serve binary already understands, plus an optional comptime zigbase.static_routes.zig snippet for dedicated per-route shells.
  • nginx — a ready-to-include nginx.nginx.conf location block, written in place of the ZigBase files above.
  • Apache — an apache.htaccess file, same rule.

Independent of that choice, every build also writes all three csp.nginx.conf, csp.apache.conf and csp.zigbase.txt at the output root — the strict Content-Security-Policy header value for every inline script the build actually emitted, hashed, with no unsafe-inline, for whichever host ends up serving the site.

public/                  <- the output tree
├── csp.nginx.conf       (written for every target)
├── csp.apache.conf
├── csp.zigbase.txt
└── demos/app/            deploy_target = "zigbase"
    ├── .spa
    └── zigbase.static_routes.zig

# deploy_target = "nginx" writes demos/app/nginx.nginx.conf instead;
# "apache" writes demos/app/apache.htaccess instead.

Who this is for

For a team that wants one binary, not a toolchain

Zigapagos is for a site that is mostly static content with a few real interactive components, built and shipped by someone who does not want to own a Node install, a bundler config and a plugin ecosystem to make that possible. One binary renders the content, Bun server-renders and bundles the islands, and the two together are the entire build.

It is not for every project. Compare lays out where Astro, Eleventy, Hugo and the generator this forks from still fit better — including an honest section on when not to reach for this one.