Skip to content

Live copy in server-rendered pages

Text a visitor can see is never replaced, so live copy is decided before a slot is shown. A page rendered in the browser decides it with the hold: the slot waits blank for a moment, then live copy or your fallback lands once. A page rendered on your server can do better for visitors whose context has been seen before: it can read the live copy already cached for that context and put it in the HTML as final text.

When there is nothing cached, the island renders its reservation exactly as before, and the browser’s stream and hold take over. First paint is never blocked on the read.

getIslandCopy, from @sparkletree/react/server (or @sparkletree/core/server), is a server-only function:

import { getIslandCopy } from "@sparkletree/react/server";
const read = await getIslandCopy({
publishableKey: process.env.SPARKLETREE_PUBLISHABLE_KEY!,
campaignId: "cmp_123", // or surfaceId
islands: ["hero", "cta"],
fallbacks: { hero: heroFallback, cta: ctaFallback }, // the islands' own fallbacks
declared: { page: "home" }, // the same values as the provider's context
headers: requestHeaders, // a Headers object or a plain record
budgetMs: 250, // the default; 1000 at most
});
read.islands.hero; // { copy, contentSource: "cached", freshness, variantId, campaignId } or null
read.islands.cta; // the same, or null
read.context; // pass to <SparkletreeProvider serverContext>
read.elapsedMs; // how long the read took, for your logs
  • Strictly a read. Nothing is generated and nothing is counted. The impression records in the browser when the island is seen, with the copy’s variant and provenance (cached, or cached-stale for copy past its freshness window).
  • Never throws, never waits past the budget. A miss, an error, a timeout, a malformed key or a call in a browser all return null islands. The budget is the total wait, network included.
  • One read, one variant. Name every island you render in one call. In the App Router, wrap the call in React’s cache so islands in separate Suspense boundaries share it.
  • Live or test key. The read takes your publishable key from the server, so no origin is involved: a live key (st_pk_live_) in production, a test key (st_pk_test_) in local development.
  • Pass the islands’ fallbacks. Each fallback sets its slot’s size, and its length goes with the request as that field’s length budget, exactly as the island’s stream in the browser sends it, so the read finds the copy the stream cached. A field a fallback does not have is left out of the result.

Only an allow-list of the visitor’s request headers is read:

Header Becomes
Accept-Language The language (well-formed entries only).
Sec-CH-UA-Mobile, else User-Agent The device bucket: mobile for phones, desktop for everything else, tablets included, as in the browser.
x-vercel-ip-timezone, else cf-timezone The time zone, and from it the time of day, bucketed exactly as the browser does. Pass fallbackTimezone for hosts that send neither.

The visitor’s IP address, cookies, full user agent, referrer and location are never read or sent. The key travels as an Authorization header, never in the URL.

Copy is cached per context. For the browser’s stream to replay the entry the server read, or to fill the one it missed, it has to send the same values. Pass read.context to the provider:

<SparkletreeProvider publishableKey={key} serverContext={read.context}>

For that page view the browser then sends the server’s device type, time of day, time zone and language instead of reading its own (a value the server left out is left out), and pins the variant the server decided before any island’s stream opens. Values in the provider’s context still win, so keep the declared placement the same on both sides; custom dimensions (custom: { tier: "pro" }) are sent as x.tier by both. Without serverContext nothing changes.

Each read names its page view. A provider that stays mounted across navigations (an app-root layout) starts a new page view when it is given the next read’s context: pins, slot memory and impressions start fresh, before any island below opens its stream, and copy from the new read is shown with one cross-fade. Copy for the same page view is never swapped: a different initialCopy with the same serverContext is not shown until the next view.

<Hero
campaignId="cmp_123"
fallback={heroFallback}
initialCopy={read.islands.hero ?? undefined}
/>
  • A hit renders visibly from the first byte, settled and final, stamped data-content-source="cached", in the slots your fallback sets. It is the island’s whole copy: the stream still brings theme, media and the button’s action, but writes no text. A field the cached copy has no text for shows your fallback; a field your fallback does not have is not shown.
  • A miss (undefined) renders the reservation: your fallback, invisible, holding its space. In the browser the hold runs as usual.

Stream the shell first and each island when its read is back. Never await the read above a Suspense boundary; that would hold the whole page on it.

app/page.tsx
import { IslandReservation } from "@sparkletree/react";
import { Suspense } from "react";
export default function Page() {
return (
<main>
<Suspense fallback={<IslandReservation island="hero" fallback={heroFallback} />}>
<LiveHero />
</Suspense>
</main>
);
}
// app/live-hero.tsx (a server component)
import { Hero, SparkletreeProvider } from "@sparkletree/react";
import { getIslandCopy } from "@sparkletree/react/server";
import { headers } from "next/headers";
import { cache } from "react";
const readCopy = cache(async () =>
getIslandCopy({
publishableKey: KEY,
campaignId: CAMPAIGN,
islands: ["hero", "cta"],
fallbacks: { hero: heroFallback, cta: ctaFallback },
headers: await headers(),
}),
);
export async function LiveHero() {
const read = await readCopy();
return (
<SparkletreeProvider publishableKey={KEY} serverContext={read.context}>
<Hero
campaignId={CAMPAIGN}
fallback={heroFallback}
initialCopy={read.islands.hero ?? undefined}
/>
</SparkletreeProvider>
);
}

IslandReservation renders the island’s reservation as static markup: the same elements and the same invisible fallback, so the slot owns its space from first paint, and a hit arriving makes text appear without replacing anything. Give it the same fallback, className, style and (for a cta) href as the island.

The reservation and the island are one slot, with one clock. The reservation holds blank for at most 5 seconds from its first paint, then shows your fallback. When the island arrives inside that time, its copy lands in the blank slot, and a miss holds only what is left of the 2.5 second budget. When it arrives later, its copy is not shown: your fallback stays, final for the view. This works before the page’s JavaScript has loaded too: the reservation and the island each carry a small inline script. If your Content Security Policy requires a nonce for inline scripts, pass it as nonce to IslandReservation, Hero and Cta.

Reading headers() makes the route dynamic, and the read is cache: "no-store": the HTML is per request and never shared between visitors. Do not put a page that reads copy behind a shared cache.

getServerSideProps finishes before any HTML is sent, so the read’s budget adds to the time to first byte. Keep it small; a slow read simply returns no copy.

export const getServerSideProps = async ({ req, res }) => {
const read = await getIslandCopy({
publishableKey: KEY,
campaignId: CAMPAIGN,
islands: ["hero", "cta"],
fallbacks: { hero: heroFallback, cta: ctaFallback },
headers: req.headers,
budgetMs: 150,
});
res.setHeader("Cache-Control", "private, no-store");
return { props: { read } };
};

The result is plain JSON and passes through props as is.

Do not call getIslandCopy for HTML that is built once and served to everyone. There is no visitor at build time, and one visitor’s copy baked into shared HTML would be shown to all of them. Render the islands without initialCopy: they carry the reservation, and in the browser they hold while the stream brings live copy, or your fallback lands and stays. That is an explicit choice, not a degraded mode: it is exactly how a client-rendered page behaves.

example-nextjs has all three: the App Router with Suspense, the Pages Router, and a static page.