NewServer-first Preact with islands & streaming

Server first.
Hydrate only what moves.

Preact Progressive renders every request to HTML on the server. Mark interactive boundaries with use client. The rest never ships to the browser.

pnpm dlx degit jacob-ebey/preact-progressive-template my-new-app
0 kB
by default
Per-island
payloads
Streaming
Suspense-ready
Getting Started | Docs

Getting Started

You will get a running app in under a minute, then see how the pieces fit together.

Preact Progressive renders on the server. Every request returns HTML. The browser hydrates only the parts you mark interactive with use client directives. The rest never ships.

Try it now

The fastest way to feel that split is the starter template:

pnpm dlx degit jacob-ebey/preact-progressive-template my-new-app
cd my-new-app
pnpm i
pnpm dev

Open the page. View Source. You will see full HTML with no bundle blocking first paint. That is the server doing its job.

When you are ready to understand the model, work through the Guide. Each step adds one idea: a server page, an island, props, an event, then streaming. Or keep reading here for manual setup.

Manual setup

You only need four pieces: Preact for rendering, Vite for the build, Preact Progressive for the server and client runtime, and Hono for serving. Hono is optional but recommended because the docs assume its middleware.

1. Install packages

pnpm i preact-progressive preact preact-render-to-string hono
pnpm i -D vite

Asset wiring in this guide uses @hiogawa/vite-plugin-fullstack. Bring your own if you prefer:

pnpm i -D @hiogawa/vite-plugin-fullstack

2. Configure Vite with two environments

Your app builds twice from the same source. The client environment builds the browser entry. The ssr environment builds the server entry. The Preact Progressive plugin connects the two so islands know which module to load.

import fullstack from "@hiogawa/vite-plugin-fullstack";
import preact from "@preact/preset-vite";
import preactProgressive from "preact-progressive/vite";
import { defineConfig } from "vite-plus";

export default defineConfig({
  environments: {
    client: {
      build: {
        manifest: true,
        outDir: "./dist/client",
        rolldownOptions: {
          input: {
            index: "./src/entry.client.ts",
          },
        },
      },
    },
    ssr: {
      build: {
        outDir: "./dist/ssr",
        emitAssets: true,
        rolldownOptions: {
          input: {
            index: "./src/entry.server.tsx",
          },
        },
      },
    },
  },
  plugins: [preactProgressive(), preact(), fullstack()],
});

3. Create your server entry

This is where routes live. The preactRenderer wraps each page in your Shell. Your page stays a normal component.

import { Hono } from "hono";
import type { ComponentChildren } from "preact";
import { useState } from "preact/hooks";
import { preactRenderer, useRequestContext } from "preact-progressive/hono";
import { Router } from "preact-progressive/router";

import clientAssets from "./entry.client.ts?assets=client";
import ssrAssets from "./entry.server.tsx?assets=ssr";

const app = new Hono();
app.use(preactRenderer(({ children }) => <Shell>{children}</Shell>));

app.get("/", (c) => {
  function Counter() {
    "use client";
    const [count, setCount] = useState(0);

    return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
  }

  return c.render(
    <main>
      <h1>Hello, World!</h1>
      <Counter />
    </main>,
  );
});

export default app;

const assets = clientAssets.merge(ssrAssets);

function Shell({ children }: { children?: ComponentChildren }) {
  const c = useRequestContext();
  const skipShell = !!c.req.header("Accept")?.match(/\btext\/x-component\b/);

  // Skip the shell on client navigations
  if (skipShell) return children;

  return (
    <html lang="en">
      <head>
        {assets.css.map((f) => (
          <link rel="stylesheet" href={f.href} />
        ))}
        <script async type="module" src={assets.entry}></script>
      </head>
      <body>
        <Router>{children}</Router>
      </body>
    </html>
  );
}

Notice the two jobs in Shell. On a normal visit it returns the full document with the Router inside. On a client navigation (when the request asks for text/x-component) it returns only the page content. That branch is what makes link clicks fast without a reload. Read Serving with Hono for the middleware version of the same idea.

4. Create your client entry

The client entry is one line. It finds islands the server rendered and hydrates them:

import { prgressiveHydrate } from "preact-progressive";

prgressiveHydrate(document.documentElement);

5. Serve the build

If you target Node, you can serve the built app with @hono/node-server:

pnpm i @hono/node-server

server.ts

import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { compress } from "hono/compress";

// @ts-ignore - dependent on build being ran
import build from "./dist/ssr/index.js";

const app = new Hono();
app.use(compress());
app.use(
  serveStatic({
    root: "docs/dist/client",
    onFound(path, c) {
      if (path.startsWith("/assets/")) {
        c.header("Cache-Control", "public, max-age=31536000, immutable");
      }
    },
  }),
);
app.route("", build);

serve(app, (info) => {
  console.log(`Listening at http://localhost:${info.port}`);
});

Now you have HTML on first load and islands that hydrate. To build intuition for each piece, read Learn in order, or walk the Guide and watch each concept paint in the preview.