Guides / Developers
The highest-fidelity way to run Spectare. Personalised content is rendered into the HTML on the server, so the visitor sees their page at first paint: no loading shimmer, no layout shift, no swap. This walkthrough takes about 30 minutes and works on all plans.
What you get
Before you start
Open your Spectare admin and go to Settings → API key. Copy the key: it starts with sk_. This key authorises server-to-server calls and must stay on the server. Never expose it in client code or a NEXT_PUBLIC_ variable.
# .env.local
SPECTARE_API_KEY=sk_your_workspace_keyThe package gives you PersonalisedSection (a client component that renders server content immediately, then reconciles on the client) and ComponentRenderer (renders an assembly array, optionally filtered by zone).
npm install @spectare-personalisation/reactPut the call to /api/assemble/ssr in one helper so every page shares it. The endpoint takes your orgSlug plus optional visitor signals and returns the assembly, the classified intent, and whether it came from cache. Keep the helper fail-safe: if Spectare is unreachable, return null and let the page show its default copy. Personalisation should never be able to take the page down.
// lib/spectare.ts
import 'server-only';
export type AssemblyItem = { component: string; slots: Record<string, unknown> };
export type SsrResult = {
assembly: AssemblyItem[];
intent: { audience: string; stage: string };
cached: boolean;
};
// One server-to-server call: classify intent + assemble the page.
// Never throws: on any failure it returns null so the page can render its default copy.
export async function getServerAssembly(signals: {
pageHint?: string;
referrer?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmTerm?: string;
}): Promise<SsrResult | null> {
try {
const res = await fetch('https://spectare.ai/api/assemble/ssr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.SPECTARE_API_KEY}`,
},
body: JSON.stringify({ orgSlug: 'your-workspace-slug', ...signals }),
// Personalisation is per-visitor: never let Next.js cache this response.
cache: 'no-store',
});
if (!res.ok) return null;
return (await res.json()) as SsrResult;
} catch {
return null;
}
}Fetch /api/assemble/ssr
succeeds
assembly, intent, cached
fails or times out
Returns null
page renders its default copy
Personalisation should never be able to take the page down.
cache: 'no-store'? Each visitor gets a different assembly, so you must not let Next.js Data Cache or full-route cache freeze one visitor’s result and serve it to everyone. Spectare does its own caching server-side (keyed by intent), which is why warm requests are still fast even with no-store on your side. For the same reason, do not put a personalised page behind ISR or export const revalidate.All three take the same assembly. Choose by how much of your layout Spectare owns.
A. PersonalisedSection (recommended)
Zero flickerPass the server result as initialAssembly and initialIntent. The component renders that content in the server HTML, then re-classifies on the client in the background. If the client intent matches the server (same audience and stage), nothing changes. If it differs, it swaps the result in silently.
// app/page.tsx (Server Component)
import { PersonalisedSection } from '@spectare-personalisation/react';
import { getServerAssembly } from '@/lib/spectare';
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const sp = await searchParams;
const ssr = await getServerAssembly({
pageHint: 'Homepage',
utmSource: sp.utm_source,
utmMedium: sp.utm_medium,
utmCampaign: sp.utm_campaign,
});
return (
<main>
<PersonalisedSection
orgSlug="your-workspace-slug"
pageHint="homepage"
initialAssembly={ssr?.assembly}
initialIntent={ssr?.intent}
/>
</main>
);
}B. Zone-mapped rendering
Split the assembly into zones and drop each where it belongs. hero renders HeroStatement or ImageCtaHero, body renders the content components, proof renders TestimonialCard, cta renders CtaStrip. Pass the whole array to each spot; the renderer filters by zone.
// app/page.tsx: place each zone where it belongs in your layout
import { ComponentRenderer, PersonalisedSection } from '@spectare-personalisation/react';
import { getServerAssembly } from '@/lib/spectare';
export default async function Page({ searchParams }) {
const sp = await searchParams;
const ssr = await getServerAssembly({ pageHint: 'Homepage', utmSource: sp.utm_source });
const assembly = ssr?.assembly ?? [];
return (
<main>
{/* hero zone: HeroStatement or ImageCtaHero */}
<header>
{assembly.length
? <ComponentRenderer assembly={assembly} zone="hero" />
: <PersonalisedSection orgSlug="your-workspace-slug" zone="hero" />}
</header>
<YourExistingContent />
{/* body zone: AtomCard, StatGrid, FeatureList, CodeBlock, ComparisonTable */}
<section>
{assembly.length
? <ComponentRenderer assembly={assembly} zone="body" />
: <PersonalisedSection orgSlug="your-workspace-slug" zone="body" />}
</section>
{/* cta zone: CtaStrip */}
<footer>
{assembly.length
? <ComponentRenderer assembly={assembly} zone="cta" />
: <PersonalisedSection orgSlug="your-workspace-slug" zone="cta" />}
</footer>
</main>
);
}C. Map slots into your own markup
If you already have the HTML you want and just need personalised copy inside it, read slot values straight off the assembly array. Your existing copy is the fallback when no assembly arrives.
// app/page.tsx: drop personalised copy into your own markup, no wrapper components
import { getServerAssembly } from '@/lib/spectare';
export default async function Page({ searchParams }) {
const sp = await searchParams;
const ssr = await getServerAssembly({ pageHint: 'Homepage', utmSource: sp.utm_source });
const assembly = ssr?.assembly ?? [];
const hero = assembly.find(c => c.component === 'HeroStatement')?.slots ?? {};
const cta = assembly.find(c => c.component === 'CtaStrip')?.slots ?? {};
const primary = cta.primary as { label: string; href: string } | undefined;
return (
<main>
<h1 className="hero-headline">
{(hero.headline as string) ?? 'Default headline'}
</h1>
<p className="hero-sub">
{(hero.subheading as string) ?? 'Default description copy.'}
</p>
<a href={primary?.href ?? '/signup'} className="btn-primary">
{primary?.label ?? 'Get started'}
</a>
</main>
);
}Slot reference (most common)
HeroStatement: headline, subheading. CtaStrip: primary { label, href }, headline. StatGrid: stats { value, label }[]. All nine component types are in the API reference.
The more Spectare knows about where the visitor came from, the sharper the classification. Forward these from your page. All are optional; send what you have.
| pageHint | A short description of the page, e.g. "Pricing page" or "Homepage". |
| referrer | The referring URL. In a Server Component read it from the headers() referer. |
| utmSource / utmMedium / utmCampaign / utmTerm | UTM params from searchParams. Paid and social traffic classifies far better with these. |
| pageUrl | The full page URL, used as a fallback hint when pageHint is absent. |
Known UTM patterns nudge the buyer stage automatically: a campaign containing “demo”, “trial”, or “pricing” is treated as decision-stage; paid, email, and organic-social mediums map to consideration.
Every call returns the same shape. cached: true means it was served from Spectare’s intent cache with no AI call. The first visitor in a new intent pattern gets cached: false and a build that takes a few seconds; every matching visitor after that is instant.
{
"assembly": [
{ "component": "HeroStatement", "slots": { "headline": "...", "subheading": "..." } },
{ "component": "CtaStrip", "slots": { "primary": { "label": "...", "href": "..." } } }
],
"intent": { "audience": "developer", "stage": "consideration" },
"cached": true
}The page shows default copy, never personalised.
The fetch is failing or returning null. Check SPECTARE_API_KEY is set on the server (not NEXT_PUBLIC_), that the key matches the orgSlug, and that the workspace has published atoms. A 401 means the key and slug do not match; a 404 means the slug is wrong.
Content flickers or swaps after load.
You rendered with ComponentRenderer alone (client re-classifies and swaps), or the client classified a different intent than the server. For zero swap use PersonalisedSection with initialAssembly and initialIntent so it can compare and skip the swap when they match.
Every visitor sees the same page.
A cache is freezing the response. Confirm cache: no-store on the fetch and that the route has no revalidate or ISR. A CDN in front of your app must not cache the personalised route either.
It returns a 402.
You are over the monthly personalization limit. The SSR path degrades gracefully: it keeps serving whatever is already cached rather than erroring, so returning visitors still get personalised pages. New intent patterns fall back to default until the limit resets or you upgrade.
It returns a 429.
Per-key rate limit hit. Back off and retry; if it is sustained, you are calling far more than the plan allows and should cache upstream or upgrade.