"One screen doesn't need a rewrite" made the pitch: keep your back-end exactly as it is, add React one screen at a time, without turning it into an API for a separate front-end app. This is the technical counterpart to that piece. Not why. How, one request at a time.

One line in a template kicks the whole thing off:

{% component "SearchBox" filters=filters results_count=results.count %}

Here's everything that happens between that line firing and a working, interactive search box sitting in the browser, and what that search box can and can't do once it's alive. The shape underneath borrows from two places: Hypernova, Airbnb's original render sidecar, for the batching and the idea that a failure belongs to one component, not the page. And Astro, for the islands vocabulary and the hydration model.

The lifecycle

The tag records. It doesn't render. A helper in the template layer catches the call, stores it in a per-request collector, and prints a placeholder in its place:

class Collector:
    def __init__(self):
        self.tree = []

    def component(self, name, props, hydrate=False):
        node = {
            "id": f"c{len(self.tree)}",
            "name": name,
            "props": props,
            "hydrate": secrets.token_hex(8) if hydrate else None,
        }
        self.tree.append(node)
        return f"<!--@{node['id']}-->"

Props have to be plain JSON, because they're about to cross a process boundary twice. The hydrate ID is random per response on purpose: it's the only thing that will later tell the browser which HTML on the page is allowed to become interactive.

The page finishes, then one batch goes out. Django keeps rendering everything around that placeholder exactly as it always has. Once the template is fully built, every node the collector gathered gets sent to the sidecar in a single request, not one call per widget:

def render_islands(request, response, collector):
    if not collector.tree:
        return response  # no islands, no extra request, no extra script

    batch = {"buildId": BUILD_ID, "state": request.page_state, "jobs": collector.tree}
    try:
        rendered = sidecar_client.post("/batch", json=batch, timeout=0.3)
    except (Timeout, ConnectionError):
        rendered = {"results": {}}  # every job falls through to client-render

    html = response.content.decode()
    for node in collector.tree:
        html = html.replace(f"<!--@{node['id']}-->", rendered["results"].get(node["id"], ""))

    if any(n["hydrate"] for n in collector.tree):
        payload = json.dumps({"state": request.page_state, "islands": collector.tree})
        payload = payload.replace("</", "<\\/")  # nothing in props gets to close this script early
        html = html.replace("</body>", f'<script type="application/json" id="__islands">{payload}</script>{RUNTIME_TAG}</body>')

    response.content = html.encode()
    return response

The timeout is short, around 300ms, with a circuit breaker behind it, so one slow sidecar can't make every page slow. A missing result isn't an error; it's an empty string, and an empty string is what tells the browser, later, to render that one piece client-side instead.

That batch variable, on the wire, is nothing exotic:

POST /batch HTTP/1.1
Host: sidecar.internal
Content-Type: application/json

{
  "buildId": "a1b2c3d4",
  "state": { "userId": 482, "locale": "en" },
  "jobs": [
    { "id": "c0", "name": "SearchBox", "props": { "filters": ["in-stock"], "resultsCount": 12 }, "hydrate": "9f2a7c1e" },
    { "id": "c1", "name": "Card", "props": { "title": "Wireless mouse", "price": "€24.99" }, "hydrate": null }
  ]
}

One component to hydrate, one that's staying static, both in the same request. The sidecar answers with exactly as many results as jobs it received:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "results": {
    "c0": "<div data-island-id=\"9f2a7c1e\"><div class=\"search-box\">…</div></div>",
    "c1": "<div class=\"card\"><h3>Wireless mouse</h3><p>€24.99</p></div>"
  }
}

c0 carries the data-island-id wrapper because it was marked to hydrate. c1 is just HTML, because nothing downstream needs to find it again.

The sidecar renders each job. renderToString is synchronous, so this runs in worker threads, and a worker stuck past a couple of seconds gets killed rather than left to hang the batch behind it:

import { renderToStaticMarkup, renderToString } from "react-dom/server";
import type { Request, Response } from "express";

type Job = {
  id: string;
  name: string;
  props: Record<string, unknown>;
  hydrate: string | null;
};

const registry: Record<string, React.ComponentType<any>> = { SearchBox, ResultsCount, Card }; // generated from the component folder, not hand-maintained

app.post("/batch", (req: Request, res: Response) => {
  const { buildId, jobs }: { buildId: string; jobs: Job[] } = req.body;
  if (buildId !== CURRENT_BUILD_ID) return res.status(409).end(); // refuse a stale client outright

  const results: Record<string, string> = {};
  for (const job of jobs) {
    const Component = registry[job.name];
    const props = schemas[job.name].parse(job.props); // strict; throws on anything the schema didn't expect
    const el = <Component {...props} />;
    results[job.id] = job.hydrate
      ? `<div data-island-id="${job.hydrate}">${renderToString(el)}</div>`
      : renderToStaticMarkup(el);
  }
  res.json({ results });
});

A component that was never going to be interactive gets renderToStaticMarkup, plain HTML, nothing extra. One marked to hydrate gets renderToString, wrapped in a data-island-id div the browser will go looking for. The registry lookup here, a name resolving to a function through a lookup table, is the same shape you'll see again in a moment on the Django side.

The browser mounts only what it's told to. The script the middleware wrote reads the JSON block and hydrates exactly the ids listed in it, nothing else:

type Island = {
  name: string;
  hydrate: string;
  props: Record<string, unknown>;
};

const { state, islands }: { state: unknown; islands: Island[] } = JSON.parse(
  document.getElementById("__islands")!.textContent!,
);
seedStores(state); // populate shared state before any island reads it

for (const island of islands) {
  const el = document.querySelector(`[data-island-id="${island.hydrate}"]`);
  if (!el) continue;
  import(`/chunks/${island.name}.js`).then(({ default: Component }) => {
    hydrateRoot(el, (
      <ErrorBoundary onError={reportIslandError}>
        <Component {...island.props} />
      </ErrorBoundary>
    ));
  });
}

Never document.querySelectorAll for anything that looks like a component. Mounting only the IDs named in a script the server wrote is the difference between hydrating your own islands and hydrating whatever HTML a user managed to get onto the page. Each island's code loads on demand, one chunk per component, wrapped in its own error boundary reporting back independently, because a failure belongs to the component, not the page.

That's the whole trip. One line in a template, to a hydrated, interactive component in the browser, in the time it takes one page to load. What happens next is a different question: what can this component actually do, now that it's alive?

Communication

Talking to Django

The sidecar's job ended the moment it produced HTML. An island doesn't route its clicks back through Node. It posts directly to Django, the same way a form submission always has.

That mechanism is its own post, but the short version: one name, sent as a header for a JavaScript caller or a hidden field for the no-JS fallback, resolved through a registry on the Django side, exactly the same name-to-function lookup the sidecar just did for rendering.

POST /shop/cart/ HTTP/1.1
Host: shop.example.com
X-Action: cart.add
X-CSRFToken: 8f3e...
Content-Type: application/json

{ "product_id": 482, "quantity": 1 }
HTTP/1.1 200 OK
Content-Type: application/json

{ "ok": true, "cart": { "items": 3, "total": "€74.97" } }

Django validates the input through its own form class, runs the matching function, and answers with JSON or a redirect depending on who asked. The component only ever knows a name. It never learns a URL, because there's only ever one: the page it's already on.

Isolated by design, and where the global state actually lives

Each island got its own hydrateRoot call above, and that's not an implementation detail; it's a boundary. Two islands are two separate React trees. Neither can pass props to the other, nor can it read the other's context. A bug in a search box can't reach into a results counter sitting three components away, because there's no path between them for it to travel.

That isolation becomes a problem the moment two islands need to agree on something: a filter changing what a results counter should show, both sitting in different corners of the same page. The fix lives outside React entirely, in a small atom (nanostores):

// stores/results.ts
import { atom } from "nanostores";

export const $resultsCount = atom(0);

seedStores(state), back in the browser runtime, is what actually populates it, the moment the page hydrates:

type PageState = {
  resultsCount: number;
  cart: CartItem[];
};

function seedStores(state: PageState) {
  $resultsCount.set(state.resultsCount);
  $cart.set(state.cart);
}

The filter island writes to it on every change, the counter island only ever reads:

// SearchBox.tsx
function SearchBox({ initialFilters }: { initialFilters: string[] }) {
  async function handleFilter(filters: string[]) {
    const resultsCount = await fetchResultsCount(filters); // however that count gets fetched
    $resultsCount.set(resultsCount);
  }
  // ...
}

// ResultsCount.tsx
import { useStore } from "@nanostores/react";

function ResultsCount() {
  const count = useStore($resultsCount);
  return <span>{count} results</span>;
}

Neither component imports the other. Neither knows the other exists. ResultsCount re-renders because the atom changed, not because SearchBox told it to.

Two failure modes come with that pattern, and they're worth knowing before you hit them rather than after.

A store declared at the module level leaks between users. The sidecar is a long-lived process that handles many requests, and the $cart above is a shared object across all of them unless something resets it. Seed it right before a render, and clear it right after, in the same synchronous pass:

function renderJob(job: Job) {
  $cart.set(job.props.cart as CartItem[]);
  try {
    return renderToString(<Component {...job.props} />);
  } finally {
    $cart.set([]); // gone before the next job on this worker touches it
  }
}

That only holds because renderToString is synchronous and one job runs at a time per worker. Add streaming or concurrent rendering later, and this needs a real per-request store instead, not a shared atom reset around it.

An island can hydrate against a state that's already stale. useStore always reads the atom's current, live value. If something updated the atom between the server's render and the browser's hydration, an already-stale HTML mismatch turns into a live one:

// wrong: reads whatever the atom holds right now, not what the server rendered from
const cart: CartItem[] = useStore($cart);

// right: hydrate against the exact snapshot the HTML was built from, then update
const cart: CartItem[] = useSyncExternalStore(
  $cart.listen,
  () => $cart.get(),
  () => initialProps.cart, // getServerSnapshot: frozen at render time
);

useSyncExternalStore's third argument exists for exactly this: a value pinned to the moment of the server render, so hydration never has to compare against a target that's already moved.

Filterable, sortable, shareable: when state doesn't belong in a store at all

Not every piece of shared state wants nanostores. A results counter reacting to a filter is ephemeral; nobody needs to bookmark it. A filtered, sorted, paginated table is the opposite: its state belongs in the URL, so users can share it, bookmark it, or return to it with the back button. That belongs in the URL itself, not an atom. Three separate islands, none of them importing each other, agreeing through nothing but the address bar:

// Filters.tsx, an island
function Filters() {
  const [, setQuery] = useQueryStates({ filter: "", page: 1 });
  return <input onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery({ filter: e.target.value, page: 1 })} />;
}

// SortHeader.tsx, an island
function SortHeader() {
  const [{ sort }, setQuery] = useQueryStates({ sort: "date" });
  return <button onClick={() => setQuery({ sort: sort === "date" ? "-date" : "date" })}>Date</button>;
}

// OrdersTable.tsx, an island
function OrdersTable() {
  const [{ sort, filter, page }] = useQueryStates({ sort: "date", filter: "", page: 1 });
  const { data } = useQuery({ queryKey: ["orders", sort, filter, page], queryFn: fetchOrders });
  return <table>{/* ... */}</table>;
}

Filters writes filter. SortHeader writes sort. OrdersTable only ever reads both, plus page, and fetches accordingly. No component imports another, no atom sits between them, nothing to seed and nothing to leak, because the thing they're agreeing on was never inside React to begin with. The address bar was already there.

One more thing this buys: OrdersTable's fetch is a plain GET, not a server action. Reads stay reads; only a click that actually changes something goes through the POST-based action dispatch from the section above. Three islands, one shared channel, and the channel isn't nanostores and isn't the mutation path either.

Shipping it

The browser runtime, the sidecar bundle, and the CSS all come from a single build, along with a manifest that Django reads at request time: the current build ID, the runtime script's path, and which chunks each component needs. Generate the component registry from the folder itself rather than a config someone has to remember to update, so adding an island is adding one file.

Two checks caught real breakage that nothing else did. The first is a headless-browser pass over every page: every island hydrates, no console errors, no CSP violations, and a page with zero islands ships zero scripts. The second is a back-end command that renders every page's component tree directly against the sidecar, outside the browser, so a broken prop schema fails a build instead of a user's page. Run the browser pass against a development build, too. Production React swallows some hydration mismatches that a development build reports loudly. If you only ever run the check against the build you're about to ship, the bug it exists to catch is the one it's most likely to hide.