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.
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.
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.
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.
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.
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.
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.
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
.spamarker file the stockzigbase servebinary already understands, plus an optional comptimezigbase.static_routes.zigsnippet for dedicated per-route shells. - nginx — a ready-to-include
nginx.nginx.conflocation block, written in place of the ZigBase files above. - Apache — an
apache.htaccessfile, 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.