Integrate Nukipa into your own app: the five edges
Nukipa doesn't have to host your site. If you already run a Next.js app — your own repo, your own deploy — you can keep it and wire it to the Nukipa platform at five points.
The five edges are:
- Post rendering — render CMS post bodies through
<PostBody>, not rawinnerHTML, so interactive blocks (forms, CTA tracking) hydrate. - The same-origin relay — forms and CTA clicks POST to your route handlers, which forward to the gateway with the visitor host attached.
- The visit beacon — a cookieless page-view ping fired from middleware via
waitUntil, plus a proof-of-JS confirm beacon in the layout. - Host → tenant resolution — the gateway figures out which tenant you are from the host header. You feed it the live visitor host, with an env-var fallback.
- Dynamic robots / sitemap (and a static
llms.txt) — composed off the live request host so they're correct on custom domains, staging, and preview URLs.
This guide assumes a Next.js 15 App Router app on a runtime that sets x-forwarded-host (the SDK names Vercel, Cloudflare, and Amplify as runtimes that do). The reference app these snippets are checked against is the Nukipa website itself — but read the caveat in edge 2: it implements only the CTA and contact relays, not the package's default form-relay paths.
[!NOTE] The public surface is larger than these five edges. Gated posts, lead-magnet audits (
queued|running|succeeded|failed), and newsletters (pending|active, double-opt-in) are additional/public/v1/*flows with their own states. This guide covers the five edges every content site needs; the others are out of scope here.
Install
Two packages:
npm install @nukipa/site-sdk @nukipa/post-renderer-react
@nukipa/site-sdk— a typed client for the public API (/public/v1/*): tenant card, posts, folders, forms, visit + CTA pings. The runtime is a small hand-rolled fetch wrapper; types are generated from the gateway's OpenAPI spec (openapi.public.yaml).@nukipa/post-renderer-react— the React adapter that renders a post body and hydrates its interactive islands.
Environment variables:
| Var | Required | Purpose |
|---|---|---|
NUKIPA_GATEWAY_URL |
yes | Base URL of the Nukipa public API, e.g. https://api.nukipa.com. |
NUKIPA_TENANT_HOST |
no | A fallback host for build-time / edge contexts that have no request headers. Not a hard override — see edge 4. |
NEXT_PUBLIC_NUKIPA_GATEWAY_URL |
only if a gate form runs client-side | Same gateway URL, exposed to the browser. |
NUKIPA_DEV_HOST |
dev only | localhost resolves to no tenant. Pin a real tenant host while developing (e.g. www.nukipa.com). Guarded by NODE_ENV so it can never affect production. |
[!WARNING] Don't scatter raw
fetch()calls to/public/v1/*across your app. Centralise client creation in one file (below). The SDK handles host resolution, caching, and visitor headers consistently — bypassing it gets one of those wrong every time. (Version pinning viaX-Nukipa-Site-Versionis also an SDK option, but the integrate-your-own-app path here never sets it — see edge 4.)
The shared client
Everything starts here. Server components and route handlers read request headers via Next's headers(); middleware can't, so it gets its own factory that takes the NextRequest directly.
// src/lib/nukipa.ts
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { createNukipaClient, type NukipaClient } from '@nukipa/site-sdk';
const GATEWAY_URL = process.env.NUKIPA_GATEWAY_URL;
if (!GATEWAY_URL) {
// Fail loud at module load — otherwise the first SDK call throws a
// generic 500 on every page.
throw new Error('NUKIPA_GATEWAY_URL is not set.');
}
const TENANT_HOST = process.env.NUKIPA_TENANT_HOST?.trim() || null;
const DEV_HOST =
process.env.NODE_ENV === 'development'
? process.env.NUKIPA_DEV_HOST?.trim() || null
: null;
/** For server components / route handlers — reads headers(). */
export async function getNukipaClient(): Promise<NukipaClient> {
const h = await headers();
return createNukipaClient({
gatewayUrl: GATEWAY_URL!,
getHost: () =>
DEV_HOST || h.get('x-forwarded-host') || h.get('host') || TENANT_HOST || '',
});
}
/** For edge middleware — headers() is unavailable; pass the request. */
export function getMiddlewareClient(req: NextRequest): NukipaClient {
return createNukipaClient({
gatewayUrl: GATEWAY_URL!,
getHost: () =>
DEV_HOST || req.headers.get('x-forwarded-host') || req.headers.get('host') || TENANT_HOST || '',
// Next 15 removed NextRequest.ip — read the forwarded header.
getIp: () =>
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip')?.trim() ||
null,
getUserAgent: () => req.headers.get('user-agent'),
getReferer: () => req.headers.get('referer'),
});
}
The getHost resolver is the heart of edge 4 — covered in detail below. getJson GETs default to next.revalidate = 60; pass defaultRevalidate: false to opt a client out of caching entirely (it sets cache: 'no-store'). Individual reads can also override per-call — getAuditRun passes false to poll without caching.
Edge 1 — render post bodies with <PostBody>, not innerHTML
A CMS post comes back from getPostBySlug as a FullPost: a body HTML string plus structured components (CTAs, lead forms, contact forms, carousels) and sources. If you dump body into dangerouslySetInnerHTML yourself, the static HTML renders but nothing interactive works — no form submits, no click tracking.
<PostBody> renders the same HTML and then hydrates the interactive islands after mount.
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { PostBody, renderSourcesList } from '@nukipa/post-renderer-react';
import { getNukipaClient } from '@/lib/nukipa';
export const revalidate = 60;
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const client = await getNukipaClient();
const post = await client.getPostBySlug(slug);
if (!post) notFound();
// `unlisted` is NOT in the SDK's FolderRef type ({ slug, name }) — the
// platform tags it at runtime, so read it through a cast. See edge 5.
if ((post.folder as { unlisted?: boolean } | null)?.unlisted) notFound();
const sourcesHtml = renderSourcesList(post.sources ?? []);
return (
<article className="prose-body">
<h1>{post.title}</h1>
<PostBody
body={post.body ?? ''}
components={post.components ?? []}
sources={post.sources ?? []}
postId={post.id}
lang={post.language ?? undefined}
/>
{sourcesHtml && <div dangerouslySetInnerHTML={{ __html: sourcesHtml }} />}
</article>
);
}
What hydrates:
| Island | Behaviour |
|---|---|
cta |
The anchor stays server-rendered; a click-tracker is added on top. |
form |
Mounts a lead-gen form against the form slug. |
contact-form |
Mounts a contact form reading its schema from the DOM. Skipped if there's no postId. |
carousel |
Prev/next handlers. No autoplay. |
chart, widget |
Not hydrated — placeholder HTML only. Wire per-tenant if you need them. |
The CTA anchor is hybrid by design: search engines and no-JS clients follow the real href; the tracker is purely additive. That's why you keep the SSR HTML rather than replacing the link.
[!TIP]
<PostBody>ships unstyled — it only puts classnames on bare DOM (static blocks usebp-*classes; forms uselead-form__*/contact-form__*). Import the stylesheet once in your root layout:import '@nukipa/post-renderer-react/styles.css';
A couple of behaviours worth knowing:
- Where the islands POST is set by the
endpointsprop, which defaults to/api/forms,/api/cta-clicks, and/api/contact-form-submissions(DEFAULT_ENDPOINTSinPostBody.tsx). The static HTML always renders correctly; if a relay path doesn't exist in your app, that island's POST simply 404s and the click/submit is dropped. Wire the relays you actually use, and pointendpointsat the paths you implement — see edge 2. - Gated posts carry
is_gatedplusgate_after_paragraph(the body is cut after that many paragraphs) andgated_form_slug/gated_form_name/gated_form_fields, so you can render an unlock form below the truncated body. The grounding sources don't specify what else the gated payload omits beyond the paragraph cut — treat the locked content as "not present until unlocked" rather than assuming a specific field is cleared.
[!WARNING] The published
@nukipa/post-renderer-reactREADME is out of date. It shows<PostBody ... client={client} />and a "Passclient" section claiming islands no-op without it. The shippedPostBodyPropshas noclientprop — it'sendpointsonly, and the CTA island POSTs directly toendpoints.ctaClicks. Follow the code (and this guide), not that README section.
Edge 2 — the same-origin CTA/forms relay
The browser cannot set X-Forwarded-Host. That header is how the gateway resolves which tenant a request belongs to (the gateway's public CORS doesn't even include it in allowedHeaders, so a browser couldn't spoof it). So forms and CTA clicks can't POST straight to the gateway — they'd arrive with no resolvable host.
The fix: the islands POST same-origin to route handlers your app owns, and your server attaches the host before forwarding to the gateway.
The hydration code posts these shapes:
forms → POST { slug, ...values } → /public/v1/forms/:slug/submit
ctaClicks → POST { cta_id, cta_label, cta_url, post_id,
page_path, referrer, user_agent } → /public/v1/cta-clicks
contactForms → POST { component_id, values } (postId in the path) → /public/v1/posts/:postId/contact-form-submissions
CTA clicks use navigator.sendBeacon when available (it survives the unload that a click navigation triggers), falling back to fetch(..., { keepalive: true }).
[!WARNING] What the reference app actually ships. Its
app/api/directory hascontact,cta-clicks,lead-magnet,revalidate,site-audit, andwhitepaper. There is no/api/formsand no/api/contact-form-submissions— the package's default relay paths for the form and contact islands. So with<PostBody>left at its defaults, a lead-form or contact-form island in a post would POST to a route that doesn't exist in this app. The two snippets below are: (a) the real CTA relay lifted from the repo, and (b) an illustrative form-relay pattern you'd add yourself — the repo's only SDK-based submit relay is the fixed-slugapp/api/contact/route.ts, not a dynamic[slug]handler.
The CTA relay is the minimal example. It resolves the tenant host server-side and forwards. Tracking must never surface an error to the click, so it swallows everything and always answers 204:
// src/app/api/cta-clicks/route.ts (verbatim from the reference app)
export async function POST(req: Request) {
let payload: Record<string, unknown> = {};
try {
payload = await req.json();
} catch {
// sendBeacon / keepalive fetch may deliver an empty or odd body.
return new Response(null, { status: 204 });
}
try {
await fetch(`${process.env.NUKIPA_GATEWAY_URL}/public/v1/cta-clicks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forwarded-Host': process.env.NUKIPA_TENANT_HOST ?? '',
},
body: JSON.stringify(payload),
});
} catch {
// Tracking failures degrade to gaps in analytics; never block the click.
}
return new Response(null, { status: 204 });
}
[!NOTE] Attribution caveat on the raw-fetch CTA relay. This route forwards
payloadbut sets onlyX-Forwarded-Host— it doesn't pass the visitor'sUser-AgentorReferer. The gateway'scta-clicksforward stampsUser-Agent/Refererfrom its own view of the request, which here is your Next relay server, not the browser. So CTA clicks recorded this way carry the relay's UA/referer, not the visitor's. (Theuser_agentfield inside the JSON payload is still the browser's, since the island readsnavigator.userAgent.) The SDK path (getNukipaClient()→getUserAgent/getReferer) preserves the visitor's headers; the raw fetch doesn't.
For form submits, route through the SDK instead of raw fetch, so host resolution and visitor headers are handled for you. The reference app does exactly this for its contact page — a fixed slug, not a dynamic passthrough:
// src/app/api/contact/route.ts (verbatim from the reference app)
import { NextResponse, type NextRequest } from 'next/server';
import { getNukipaClient } from '@/lib/nukipa';
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
}
// Minimal server-side guard for the required fields.
if (!body?.email || !body?.name || !body?.message) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 422 });
}
try {
const client = await getNukipaClient();
await client.submitForm('contact', body); // fixed slug
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Submission failed';
return NextResponse.json({ error: message }, { status: 502 });
}
}
If you want to relay the post-body form island (default endpoint /api/forms), the pattern is a dynamic [slug] handler — this is illustrative, not a file in the repo:
// src/app/api/forms/[slug]/route.ts (illustrative — add this yourself)
import { NextResponse, type NextRequest } from 'next/server';
import { getNukipaClient } from '@/lib/nukipa';
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const body = await req.json();
try {
const client = await getNukipaClient();
const result = await client.submitForm(slug, body);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Submission failed';
return NextResponse.json({ error: message }, { status: 502 });
}
}
The contact-form island POSTs { component_id, values } to ${endpoints.contactForms}/${postId}; relay it the same way with submitContactForm(postId, body).
[!TIP] Two valid relay styles: raw
fetch+ a hard-codedNUKIPA_TENANT_HOST(fine for fire-and-forget analytics like CTA clicks), orgetNukipaClient(), which prefers the live visitor host and forwards the visitor's UA/referer. Use the SDK route for anything you care about attributing.
What can go wrong (per relay)
On the gateway, all of these land on /public/v1/*, which reads the host from X-Forwarded-Host (falling back to the literal Host) and forwards to the owning service. The failure surface:
| Condition | Gateway / client behaviour |
|---|---|
| Form submit, upstream 4xx (e.g. validation) | Gateway forwards any status < 500 verbatim — the visitor sees the upstream 4xx body. The SDK throws NukipaApiError(status) on any non-2xx, which your relay should catch (the reference contact route returns 502 on catch). |
| Form / contact upstream ≥ 500 | Gateway's axios rejects and the request hits next(err) (the error handler), so the relay's SDK call throws. |
| Rate limit hit | submitLimiter caps submits at 2000/min, visitIngestLimiter caps visits + CTA clicks at 1200/min, both keyed per gateway IP. Over budget returns 429 with { error: { code: 'too_many', message: 'rate limit' } }. |
| Reads | readLimiter is a no-op passthrough — reads aren't bucketed at all. Don't expect a 429 on GETs. |
| CTA click / visit, tracking failure | Gateway answers 204 (never fails the click). recordVisit returns null on any non-201, including the gateway's safety 204 — a "successful" call can still mean no visit row was written. |
Edge 3 — the cookieless visit beacon
Page views are recorded in two halves. The beacon code states its own privacy intent plainly: no cookies, no storage, no device fingerprint, the nonce ephemeral and per-pageview — which (per the code comment) is meant to keep it out of ePrivacy Art 5(3) and GDPR scope. Treat that as the implementation's stated intent, not a settled legal ruling.
3a. The server-side visit ping (middleware)
On every page navigation, middleware fires recordVisit. Two things make this work on edge runtimes:
event.waitUntil, not bare fire-and-forget. Vercel's edge runtime freezes the function the moment the response returns, dropping any in-flight async that isn't registered withwaitUntil. Without it, visits are silently under-recorded.- A per-pageview nonce is minted here and passed into the visit row and exposed to the layout via a request header, so the layout can render the matching confirm beacon (3b).
// src/middleware.ts
import { NextResponse, type NextRequest, type NextFetchEvent } from 'next/server';
import { getMiddlewareClient } from '@/lib/nukipa';
export async function middleware(req: NextRequest, event: NextFetchEvent) {
const url = req.nextUrl;
const client = getMiddlewareClient(req);
// Per-pageview nonce for the cookieless proof-of-JS beacon. Ephemeral,
// per-pageview, no storage. crypto.randomUUID() is a Web Crypto global —
// edge-runtime safe.
const beaconNonce = crypto.randomUUID();
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-nukipa-nonce', beaconNonce);
const res = NextResponse.next({ request: { headers: requestHeaders } });
// NOT bare fire-and-forget — register with waitUntil so the edge runtime
// doesn't drop the in-flight ping when the response returns.
event.waitUntil(
client.recordVisit({
path: url.pathname,
session_id: req.cookies.get('nk_sid')?.value || null,
client_nonce: beaconNonce,
utm: {
source: url.searchParams.get('utm_source') || undefined,
medium: url.searchParams.get('utm_medium') || undefined,
campaign: url.searchParams.get('utm_campaign') || undefined,
content: url.searchParams.get('utm_content') || undefined,
},
}),
);
return res;
}
// Skip static assets, Next internals, and API routes — otherwise every JS
// chunk, image, and favicon hit records a visit.
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon\\.ico|.*\\.(?:png|jpe?g|webp|gif|svg|ico|woff2?|css|js|map)).*)',
],
};
recordVisit swallows its own errors, so a slow gateway can never block a page load — but note it returns null on any non-201 response (including a 204), so a non-throwing call doesn't guarantee a row was written. It also injects visitor_ip into the request body (not just the header) — serverless runtimes strip X-Forwarded-For on outbound calls, so the header path silently loses the IP. The IP drives the day-rolling visitor fingerprint behind the Visitors KPI; without it that column stays NULL and Visitors renders as — on the dashboard. That's why getMiddlewareClient wires up getIp.
[!NOTE] The reference
middleware.tsalso serves the IndexNow ownership file (a/<hex16-128>.txtpath) by readinggetTenantSeo()and echoing the key back. It's implemented in middleware on purpose — a file-basedapp/[key]/route.tswould shadow every single-segment route like/blogand/about. Out of scope for the five edges, but it lives in the same file, ahead of the visit ping so the ownership crawler's hits never land in analytics.
3b. The proof-of-JS confirm beacon (layout)
The server-side ping records that a request happened — but no-JS scrapers also generate requests. To separate real browsers from scrapers, the layout reads the nonce off x-nukipa-nonce and emits a tiny inline script. A real browser runs it and POSTs the same nonce to the gateway's confirm endpoint; the signals service then flips beacon_fired on the matching visit, so your "human" count can require a confirmed beacon.
// src/app/layout.tsx (server component)
import { headers } from 'next/headers';
import { nukipaBeaconScript } from '@nukipa/site-sdk';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get('x-nukipa-nonce');
const gateway = (process.env.NUKIPA_GATEWAY_URL || '').replace(/\/+$/, '');
const beacon =
nonce && gateway
? nukipaBeaconScript({ nonce, endpoint: `${gateway}/public/v1/signals/visits/confirm` })
: null;
return (
<html lang="en">
<body>
{children}
{beacon ? <script dangerouslySetInnerHTML={{ __html: beacon }} /> : null}
</body>
</html>
);
}
nukipaBeaconScript returns inline JS that fires a single beacon on the first of these triggers:
- a real interaction —
pointerdown,keydown,scroll, ortouchstart(this also setsinteracted: true); - the page being visible for
delayMs(default1200ms). The timer starts on first visibility — if the page loads hidden (e.g. background tab), the script defers via avisibilitychangehandler and only starts the timer once the page becomes visible; pagehide;visibilitychangetohidden(tab switch / backgrounding).
It POSTs { client_nonce, interacted } via sendBeacon, falling back to fetch with keepalive. The endpoint may be the gateway's confirm URL cross-origin or a same-origin relay path.
[!NOTE] Reading the per-request nonce in the layout makes tracked pages render dynamically — a per-pageview token can't live in a cached page anyway. That's expected. The two halves are independent: the middleware ping records the visit; the layout beacon confirms a browser actually showed it.
Edge 4 — host → tenant resolution
The gateway has no idea who you are except by the host the visitor used. Get this wrong and reads come back empty or resolve to the wrong tenant. The rule:
Live visitor host first, NUKIPA_TENANT_HOST only as a fallback.
getHost: () =>
DEV_HOST // dev-only override (NODE_ENV guarded)
|| h.get('x-forwarded-host') // the real visitor host (proxies set this)
|| h.get('host') // direct hit
|| TENANT_HOST // fallback for header-less contexts
|| ''
This order matters, and the reason is a real bug it fixes. An env-var override that wins unconditionally breaks Google Search Console meta-tag verification on custom domains: the SDK would ask the gateway about the staging host (e.g. <slug>.sites.nukipa.io) even while Google was crawling your-domain.com. The tenant then resolves via the subdomain path — which carries no google_verification_token — so the layout's verification meta tag renders empty and Google reports "verification token could not be found."
NUKIPA_TENANT_HOST exists for contexts that genuinely have no request headers: build-time generation, some edge contexts, the fire-and-forget CTA relay. It is a fallback, not a hard override. Treat it that way.
| Context | How the host is resolved |
|---|---|
| Server component / route handler | getNukipaClient() → headers() → x-forwarded-host → host → NUKIPA_TENANT_HOST |
Middleware (no headers()) |
getMiddlewareClient(req) → same chain off req.headers |
Local dev (localhost → no tenant) |
NUKIPA_DEV_HOST pins a real tenant host; NODE_ENV-guarded so it never ships |
| Build-time / header-less | NUKIPA_TENANT_HOST fallback only |
[!NOTE] The SDK has an optional
getSiteVersionresolver that forwardsX-Nukipa-Site-Versionso the CMS reads a specific site version. The referencelib/nukipa.tsdoes not set it, so there's no version pinning in this integrate-your-own-app path — the CMS falls back to the published pointer. That option is meant for workspace-daemon-spawned processes that serve a pinned version (and for?previewtraffic), which this template isn't.
Edge 5 — dynamic robots / sitemap (and a static llms.txt)
robots.txt and sitemap.xml must be composed off the live request host, not a hard-coded domain. A static robots.txt pointing at a fixed host emits the wrong sitemap location on preview, staging, and custom-domain deploys. Both are force-dynamic Next route handlers that read the host the same way the SDK does.
robots.ts — welcome the AI/answer-engine crawlers explicitly (so the intent is auditable), disallow only the POST-only /api/ routes, and point the sitemap at the live host:
// src/app/robots.ts
import type { MetadataRoute } from 'next';
import { headers } from 'next/headers';
export const dynamic = 'force-dynamic';
const AI_CRAWLERS = [
'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web',
'anthropic-ai', 'PerplexityBot', 'Perplexity-User', 'Google-Extended',
'Applebot-Extended', 'Amazonbot', 'Bytespider', 'CCBot', 'cohere-ai',
'Meta-ExternalAgent',
];
async function resolveBaseUrl(): Promise<string> {
const h = await headers();
const host = h.get('x-forwarded-host') || h.get('host') || '';
const proto = h.get('x-forwarded-proto') || 'https';
return host ? `${proto}://${host}` : '';
}
export default async function robots(): Promise<MetadataRoute.Robots> {
const baseUrl = await resolveBaseUrl();
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/'] },
...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: '/', disallow: ['/api/'] })),
],
sitemap: baseUrl ? `${baseUrl}/sitemap.xml` : undefined,
host: baseUrl || undefined,
};
}
sitemap.ts — static routes plus your own data-driven pages, with blog posts pulled live from the platform. force-dynamic with a short revalidate so newly-published posts surface within minutes rather than on the next deploy:
// src/app/sitemap.ts
export const dynamic = 'force-dynamic';
export const revalidate = 600; // the Nukipa site-template default
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = await resolveBaseUrl(); // same host-resolution helper as robots
// ...your static + service/industry/team entries...
let postEntries: MetadataRoute.Sitemap = [];
try {
const client = await getNukipaClient();
const posts = await client.listPosts({ limit: 1000 });
postEntries = posts.map((p) => ({
url: `${baseUrl}/blog/${p.slug}`,
lastModified: new Date(p.updated_at ?? p.published_at ?? Date.now()),
changeFrequency: 'monthly',
priority: 0.6,
}));
} catch {
// Platform unreachable? Still return a valid sitemap of the static
// routes rather than failing the whole file.
}
return [/* ...static..., */ ...postEntries];
}
Two things to know here: the platform excludes unlisted-folder posts from the default listPosts result (platform behaviour, not something the SDK contract guarantees — listPosts just returns SlimPost[]), so they don't enter the sitemap; and wrapping the live fetch in try/catch means a flaky gateway degrades to a static-only sitemap instead of a broken file. Because the unlisted flag isn't typed on FolderRef, also guard the post page itself (edge 1) rather than relying on the list alone.
llms.txt — in the reference app this is a static file in public/, not a dynamic route. It's a hand-curated, machine-readable index of your key pages for AI crawlers (the llms.txt convention), with absolute URLs to your canonical domain. Because it's static, it ships as-is — there's nothing dynamic to wire. If you serve multiple hosts and need per-host URLs, promote it to a route handler that reads the host like robots.ts does; the default template doesn't, and that's fine for a single canonical domain.
Wiring checklist
npm install @nukipa/site-sdk @nukipa/post-renderer-react.- Set
NUKIPA_GATEWAY_URL(andNUKIPA_DEV_HOSTfor local dev). AddNUKIPA_TENANT_HOSTonly if you have header-less contexts. - Create
src/lib/nukipa.tswithgetNukipaClient()andgetMiddlewareClient(req). - Render post bodies with
<PostBody>; import@nukipa/post-renderer-react/styles.cssin the layout. - Add the relay routes for the islands you use, and set
<PostBody endpoints={...}>to match. At minimum: a CTA relay (/api/cta-clicks). Add a form relay (default/api/forms) and a contact relay (default/api/contact-form-submissions) only if your posts use those islands — the package's defaults point at paths your app must implement. - Record visits in
middleware.tsviaevent.waitUntil(client.recordVisit(...)); emitnukipaBeaconScriptin the layout from thex-nukipa-nonceheader. - Confirm
getHostputs the live visitor host beforeNUKIPA_TENANT_HOST. - Add
force-dynamicrobots.tsandsitemap.tsthat resolve the base URL off the request; drop a staticpublic/llms.txt.
FAQ
Can I keep my own components and only use Nukipa for the blog? Yes. Nothing here touches your page components or routing. You add a client, a renderer for post bodies, the relay routes for the islands you use, middleware, and the SEO files.
Why can't the browser POST forms straight to the gateway? Because it can't set X-Forwarded-Host, which is how the gateway resolves your tenant — and the gateway's public CORS doesn't allow that header from a browser anyway. The same-origin relay (edge 2) lets your server attach the host before forwarding.
Do the default relay paths exist if I just install the package? No. <PostBody> defaults to /api/forms, /api/cta-clicks, and /api/contact-form-submissions, but those are paths your app must implement. The reference app ships only the CTA relay and a fixed-slug contact relay; the form and contact-submission relays you add yourself (or point endpoints at the routes you do have).
Do I need the confirm beacon if I already record visits in middleware? No — they're independent. The middleware ping records the visit; the beacon only confirms a real browser rendered the page, so you can exclude no-JS scrapers from human counts. Skip it if you don't care about that distinction.
Will an unreachable gateway break my site? No, by design. recordVisit and recordCtaClick swallow errors (and recordVisit returns null rather than throwing). The sitemap falls back to static routes. The CTA relay answers 204 on failure. A slow or down platform degrades analytics; it doesn't take pages down. Form submits are different — a failed submit surfaces the upstream status (4xx verbatim, or your relay's 502 on a thrown error), because the visitor needs to know it didn't go through.
What about charts and widgets in posts? They render as placeholder HTML only — <PostBody> doesn't hydrate them. Wire them per-tenant if you need them.
Is llms.txt generated dynamically? Not in the default template — it's a static file you maintain in public/. Only robots.txt and sitemap.xml are dynamic route handlers. Promote llms.txt to a route handler yourself if you need per-host URLs.