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.
What the read is
Section titled “What the read is”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 nullread.islands.cta; // the same, or nullread.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, orcached-stalefor 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
nullislands. 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
cacheso 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.
What it sends
Section titled “What it sends”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.
The browser adopts the server’s context
Section titled “The browser adopts the server’s context”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.
Islands with and without copy
Section titled “Islands with and without copy”<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.
Next.js App Router
Section titled “Next.js App Router”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.
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.
Next.js Pages Router
Section titled “Next.js Pages Router”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.
Static pages, ISR and static export
Section titled “Static pages, ISR and static export”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
Section titled “Example”example-nextjs
has all three: the App Router with Suspense, the Pages Router, and a static
page.