◐ Shell
clean mode source ↗

GitHub - capri-js/capri: Build static sites with interactive islands

Capri

Capri is a static site generator for React and Preact that uses island architecture: only interactive components hydrate, everything else ships as static HTML.

Why Capri

  • React/Preact everywhere: Pages and islands are standard components. No custom syntax.
  • Islands by convention: Add .island.tsx to opt into hydration.
  • Static-first: Render at build time and deploy to any static host.
  • Vite-native: Fast dev server, predictable builds, easy integration.

Quick Start

# React
npm create capri@latest my-site --template react

# Preact
npm create capri@latest my-site --template preact

Core Concepts

Islands

Components without .island.tsx render to static HTML and ship zero JavaScript. Components with .island.tsx hydrate on the client.

// Counter.island.tsx
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

Entry Points

Capri uses two entry files for a clean split between static rendering and client-side previews. The server entry builds static HTML at build time. The client entry is used in dev mode and, when connected to a CMS, enables live previews (and even in-place editing) without a server-side rendering round-trip. Previews render in the browser from the same static output.

// main.tsx
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { Router } from "./router.jsx";

ReactDOM.createRoot(document.body).render(
  <StrictMode>
    <Router path={window.location.pathname} />
  </StrictMode>,
);
// main.server.tsx
import { StrictMode } from "react";
import { prerenderToNodeStream } from "react-dom/static";
import { Router } from "./router.jsx";

export async function render(url: string) {
  return {
    body: prerenderToNodeStream(
      <StrictMode>
        <Router path={url} />
      </StrictMode>,
    ),
  };
}

Routing

Your Router decides what to render based on the path prop:

export function Router({ path }: { path: string }) {
  if (path === "/") return <Home />;
  if (path === "/about") return <About />;
  throw new Error("Not found");
}

A simple file-based router using import.meta.glob():

const modules = import.meta.glob("./pages/**/*.tsx", { eager: true });

export function Router({ path }: { path: string }) {
  const normalizedPath = path === "/" ? "/root" : path;
  const module = modules[`./pages${normalizedPath}.tsx`];
  if (!module) throw new Error(`Page not found: ${path}`);
  const Page = module.default;
  return <Page />;
}

Island Loading Strategies

// Visible.island.tsx
export const options = {
  loading: "visible",
};
  • eager (default): hydrate immediately
  • idle: hydrate when the browser is idle
  • visible: hydrate when the island enters the viewport

Media Queries

// MediaQuery.island.tsx
export const options = {
  media: "(max-width: 768px)",
};

Data Fetching

Entry-level (recommended for CMS/API data)

// main.server.tsx
const posts = await fetchPosts();

export async function getStaticPaths() {
  return ["/", ...posts.map((p) => `/blog/${p.slug}`)];
}

export async function render(url: string) {
  const post = posts.find((p) => `/blog/${p.slug}` === url);
  return {
    body: renderPost(post),
  };
}

React use() during static rendering

import { use } from "react";

const cache = new Map();

export function useFetch<T>(url: string): T {
  let promise = cache.get(url);
  if (!promise) {
    promise = fetch(url).then((res) => res.json());
    cache.set(url, promise);
  }
  return use(promise);
}

Static Paths

// main.server.tsx
export function getStaticPaths() {
  return ["/", "/about", "/blog/post-1", "/blog/post-2"];
}

Configuration

capri({
  prerender: "/",
  followLinks: true,
  islandGlobPattern: "/src/**/*.island.*",
  spa: "/preview",
  inlineCss: false,
  sitemap: {
    origin: "https://example.com",
  },
});

Deployment

This runs two Vite builds: a client bundle for island hydration and an SSR build for static HTML generation. Deploy the dist directory to any static host.

Contributing

Contributions are welcome. See the GitHub repository for details.

License

MIT