Fresh and Deno Islands: Zero-Config Partial Hydration

Most islands frameworks ask you to annotate components — a directive, a directory-level export, a compiler hint. Fresh, running on Deno, takes the position that the annotation should be the file’s location: anything under islands/ hydrates, everything else is server-only and ships no JavaScript at all. That single convention removes an entire category of configuration, and it also removes the escape hatches those configurations usually provide. This guide covers how the model actually executes, how partial hydration works when there is no bundler in the request path, and where the convention becomes a constraint you have to design around.

The Directory Is the Boundary A project tree divided into two zones. Routes, components and utility directories run only on the server and contribute no JavaScript to the browser. The islands directory is the single client zone: each file inside it becomes an entry point, is transpiled to a module, and is fetched when its element activates. A note points out that moving a file between the two zones changes its runtime behaviour without changing a line of its code. Two zones, decided by path SERVER ONLY · 0 KB routes/ — handlers and page components components/ — shared presentational markup utils/ — data access, formatting, auth these files may import a database client freely CLIENT ZONE · SHIPS islands/Counter.tsx → its own module islands/SearchBox.tsx → its own module islands/Filters.tsx → its own module each fetched only when its element activates Moving a file between the zones changes its runtime behaviour without changing a line of its code — review moves carefully.

Concept Definition & Scope

Fresh renders every route on the server with Preact and sends HTML. Components imported from islands/ are additionally compiled as browser modules and marked in the output; the runtime finds those markers, deserialises the props embedded beside them, and mounts the component against the existing DOM when it activates. Nothing else in the project reaches the browser.

The important structural difference from directive-based frameworks is that the decision is made once per file rather than once per usage. In Astro, the same component can be client:load on one page and client:visible on another. In Fresh, an island is an island everywhere it is used, and its activation policy is a property of the runtime rather than the call site. That trade — less flexibility, far less configuration — is the framework’s central design position.

In scope here: the boundary convention, prop serialisation, signals across the boundary, and the operational characteristics of running on Deno. Out of scope: Preact itself, and the general question of when to use islands at all, which belongs to when to use islands versus full hydration.

Technical Mechanics

A route is a plain function returning markup. The island is an ordinary import; what makes it special is only its path.

// routes/products/[id].tsx — server only. Nothing here reaches the browser.
import { Handlers, PageProps } from "$fresh/server.ts";
import { signal } from "@preact/signals";
import VariantPicker from "../../islands/VariantPicker.tsx"; // ← client zone
import PriceTable from "../../components/PriceTable.tsx";     // ← server zone
import { getProduct } from "../../utils/db.ts";               // ← server zone

export const handler: Handlers = {
  async GET(_req, ctx) {
    // Runs on the server for every request. The database client imported
    // above never enters a client bundle because utils/ is server-only.
    const product = await getProduct(ctx.params.id);
    return ctx.render({ product });
  },
};

export default function ProductPage({ data }: PageProps) {
  // A signal created in the route: its VALUE is serialised into the island's
  // props, and the client reconstructs it as a live signal on activation.
  const selected = signal(data.product.variants[0].id);

  return (
    <article>
      <h1>{data.product.title}</h1>
      {/* Server-rendered, ships zero JavaScript, still fully readable. */}
      <PriceTable variants={data.product.variants} />
      {/* The island: markup is server-rendered too, but this subtree gets a
          client module and becomes interactive when the runtime mounts it. */}
      <VariantPicker variants={data.product.variants} selected={selected} />
    </article>
  );
}

Two details in that file matter more than the rest. First, PriceTable and VariantPicker are written identically — same Preact, same props — and differ only in which directory they live in; there is no separate authoring model for server components. Second, the signal created in the route is the framework’s cross-boundary state primitive: pass the same signal to two islands and they share one client-side signal after activation, without an external store. That is a genuinely smaller mechanism than the store patterns described in sharing state across islands, and it is limited to values the serialiser can carry.

Comparison: Fresh Against Directive-Based Islands

Dimension Fresh Astro Hand-rolled loader
Boundary declaration File location Per-usage directive Manual marker plus registry
Activation policy Runtime default, per island Chosen per usage Whatever you implement
Build step Optional; transpiles on demand Required Required
Cross-island state Signals passed as props External store External store
Component model Preact only Any framework via adapters Any
Escape hatches Few by design Many Unlimited, and unsupported

The table describes a genuine spectrum rather than a ranking. A team that wants one obvious way to do things and no bundler configuration gets a great deal from the Fresh model. A team that needs the same component eager on one route and deferred on another will fight it, and should look at Astro islands and client directives instead.

One Request, End to End Five stages left to right. The handler runs on the server and loads data. The route renders to HTML with Preact. Island markers and serialised props are embedded beside the rendered markup. The document is sent, and the browser paints it with no JavaScript. Finally the runtime fetches each island module and mounts it against the existing DOM, reconstructing signals from their serialised values. Handler → render → markers → paint → mount 1 · handler loads data on the server db client stays server-side 2 · render Preact to string islands render here too 3 · markers module id + serialised props signals → values 4 · paint no script has run yet page is readable 5 · mount fetch module per island signals revived Where the time goes on a cold request Stages 1 and 2 dominate time to first byte; stage 5 dominates blocking time. They are tuned separately — a faster query does nothing for activation, and a smaller island does nothing for the first byte.

Step-by-Step Integration Pattern

  1. Start with everything server-only. Build the route with no islands at all and confirm it works as HTML — links navigate, forms post, content renders. This is your progressive-enhancement baseline, and in Fresh it is the default rather than an aspiration.

  2. Promote one component to an island. Move the file into islands/, fix the import path, and reload. The only change should be that the component becomes interactive; if anything else changes, the component was depending on something server-only.

  3. Audit what the move dragged along. The island’s imports are now client imports. A formatting helper that imports a locale bundle, or a shared module that re-exports a database client, will either bloat the module or fail to compile. This is the same barrel-file problem described in configuring SolidStart island hydration directives, and it appears in every islands framework.

  4. Pass only what the island reads. Props are serialised into the document, so a whole product record becomes bytes in the HTML for every visitor. Project to the fields the component uses.

  5. Use signals for values two islands share. Create the signal in the route, pass it to both, and let the runtime reconnect them on the client. Reach for an external store only when the sharing crosses routes or outlives the page.

  6. Run the production build before measuring anything. Development transpiles on demand, so both latency and payload numbers differ from production in ways that will mislead you.

Operating Fresh in Production

Two operational characteristics distinguish a Fresh deployment from a Node-based one, and both are worth understanding before the first release rather than during the first incident.

Dependencies are URLs, resolved at first use. There is no install step producing a directory of packages; imports resolve against a lock file and a local cache. In development this is invisible. In production it means the first cold start of a new deploy may fetch and compile modules, so the very first request after a release is slower than the rest. Pre-caching dependencies during the build, and running the optional ahead-of-time build, removes both effects. It also means a dependency’s availability is a runtime concern rather than only a build-time one, which is an argument for vendoring anything you cannot afford to lose.

Permissions are explicit. The runtime denies filesystem, network and environment access unless granted, which is a genuine security benefit and an occasional source of confusing failures — a library that reads an environment variable will simply see nothing rather than throwing a recognisable error. Grant the narrowest set that works, list them in the deployment configuration, and treat a new permission in a dependency upgrade as something to review rather than to wave through.

For the islands themselves, the operational surface is small: each island is a static module served with a content hash, so it caches indefinitely and costs nothing on repeat visits. That property is what makes the per-island split worth having, and it is covered from the delivery side in edge caching and delivery for islands. The routes are ordinary server responses, so the streaming and cache policies described there apply unchanged.

Measurement & Validation

Three checks confirm a Fresh route is behaving the way the model promises.

Count the requested modules. Load the route with an empty cache and list the scripts the browser requests. You should see the runtime plus exactly one module per island present on the page — no more. An unexpected module means a component was imported into an island and dragged into its entry point.

Confirm the zero-JavaScript baseline. Disable JavaScript and reload. Every non-island part of the page must still render and every navigation must still work. Fresh makes this easy to achieve and equally easy to lose the moment a critical control moves inside an island without a server-rendered fallback.

Measure activation separately from response time. Deno’s response time and the browser’s activation time are independent numbers with independent fixes, and reporting them together hides which one regressed. Record a mark when each island mounts and compare against the framework benchmark methodology in islands performance benchmarks by framework.

Failure Modes

1. The accidental island. A component is moved into islands/ to make one button work, and it happens to be the page’s main layout wrapper. Everything inside it now ships. Symptom: transferred JavaScript jumps by an order of magnitude for a change described as “make the button clickable”. Fix: extract the button, not the wrapper — the boundary should be the smallest element that owns behaviour.

2. Server-only imports inside an island. An island imports a utility that reads an environment secret or opens a connection. Depending on the import, this fails loudly at build or, worse, quietly ships the module’s source to the browser. Fix: keep a lint rule that forbids importing from utils/ inside islands/, and pass values as props instead.

3. Non-serialisable props. A callback passed from a route to an island cannot cross the boundary; the island receives nothing and fails at the first invocation. Fix: pass an action name or endpoint and let the island decide what to call, the same rule that governs cross-boundary prop passing everywhere else.

What the Directory Convention Buys and Costs The convention removes per-usage configuration, gives the build a trivial splitting rule and makes the boundary visible in every import path. It costs per-usage flexibility, since a component cannot be eager on one route and deferred on another, and it makes a file move a behavioural change. One convention, two columns BUYS no per-usage configuration to forget a trivial rule for splitting the build the boundary visible in every import path reviewers see it without reading the file COSTS no per-usage activation policy a file move changes runtime behaviour Preact only, by design few escape hatches when you need one

Frequently Asked Questions

Why does Fresh use a directory as the island boundary?

Because a directory is the least ambiguous declaration available. A directive on a component can be added, copied or forgotten; a file's location is visible in every code review and in every import path. The convention also gives the build a trivial rule for splitting: everything under islands/ becomes a client entry point and everything else is server-only, with no analysis required. The cost is that moving a file changes its runtime behaviour, which surprises newcomers exactly once.

Can a Fresh island contain another island?

It can import another island component, but doing so collapses the boundary: the child's code is bundled into the parent's entry point and both activate together. If the child should activate independently, render it from the route rather than from inside the parent, and pass shared state through a signal both import. This is the same nesting rule that applies to every islands framework — a boundary inside a boundary is not a boundary.

How do signals cross the server-client boundary?

A signal passed as a prop is serialised as its current value and reconstructed as a live signal on the client. Two islands that receive the same signal instance from a route end up sharing one client-side signal, which is how Fresh supports cross-island state without an external store. The constraint is that only the value crosses — a computed signal's derivation function stays on the server unless it is defined inside island code.

Does Fresh need a build step?

In development it transpiles on demand, so there is no build to run and no bundler to configure. For production a build step is available and worth using: it pre-transpiles and hashes island modules so the first request does not pay compilation cost and the assets can carry long cache lifetimes. The absence of a required build is a genuine ergonomic advantage, but shipping to production without the optional one leaves latency on the table.

← Back to Framework-Specific Islands & Streaming SSR