The SDK & post rendering — reference
This is a reference for the two npm packages a Nukipa tenant site uses to talk to the platform: @nukipa/site-sdk (the typed client for the public API) and @nukipa/post-renderer-react (the React component that renders a CMS post body and hydrates its interactive parts).
A tenant site is a normal app you host — the Next.js template is the common case, but nothing here is Next-specific except the revalidation hooks. The SDK is the read/write path to content and signals; the renderer turns a post's stored HTML into a live article with working forms and click tracking.
[!NOTE] The SDK is hand-written on top of
fetch. The TypeScript response types insrc/types.gen.tsare generated from the gateway's OpenAPI spec (apps/gateway/openapi.public.yaml), but the runtime wrapper is maintained by hand. A handful of surfaces (pages, events, nav, newsletters, audits) are still loosely typed —Record<string, unknown>with a few known fields layered on top — because those endpoints aren't in the generated types yet. Each loosely-typed group is called out in its own section below.
createNukipaClient
One factory produces the client. It's framework-agnostic: you give it a gateway URL and a set of resolver functions, and it returns an object with one method per public endpoint.
import { createNukipaClient } from '@nukipa/site-sdk';
import { headers } from 'next/headers';
const client = createNukipaClient({
gatewayUrl: process.env.NUKIPA_GATEWAY_URL!,
// Resolved per-call. Server components can read from the request.
getHost: async () => process.env.NUKIPA_TENANT_HOST
|| (await headers()).get('x-forwarded-host')
|| (await headers()).get('host')
|| ''
});
const tenant = await client.getTenant();
const posts = await client.listPosts({ limit: 10 });
await client.recordVisit({ path: '/blog' });
Options
Every resolver has the signature () => string | Promise<string> | null | Promise<null> — it may be synchronous or return a promise, and may return null.
| Option | Type | Required | Purpose |
|---|---|---|---|
gatewayUrl |
string |
yes | Gateway base URL, e.g. https://api.nukipa.com. Trailing slashes are stripped. |
getHost |
resolver (sync or async) | yes | Returns the visitor host. Forwarded upstream as X-Forwarded-Host. This is how the gateway resolves which tenant the request belongs to. |
getIp |
resolver | no | Forwarded as X-Forwarded-For, and for recordVisit also injected into the body as visitor_ip. Drives the day-rolling visitor fingerprint. |
getUserAgent |
resolver | no | Forwarded as User-Agent. |
getReferer |
resolver | no | Forwarded as Referer. |
getSiteVersion |
resolver | no | Forwarded as X-Nukipa-Site-Version. Narrow purpose — see below. |
fetch |
typeof fetch |
no | Override the fetch implementation (Node < 18, tests). Defaults to globalThis.fetch. |
defaultRevalidate |
number | false |
no | Default next.revalidate for GET methods. Defaults to 60 (seconds). false means cache: 'no-store'. |
The resolvers are called fresh on every request — that's deliberate, so a single client instance can serve many tenants/visitors. The client reads them inside buildHeaders() per call and only attaches a header when the resolver returned a truthy value.
getSiteVersion is not a general-purpose version pin. The code uses it for one case: when the consumer runs inside a workspace daemon process pinned to a specific version. Without it the CMS falls back to the published pointer — which is wrong for preview traffic and for any spawned process serving a non-current version. For a normal published tenant site, leave it unset.
Why getHost matters
The gateway is multi-tenant. It uses the forwarded host to look up which tenant's content to serve. Browsers can't set X-Forwarded-Host (it isn't in the gateway's CORS allowedHeaders), so host resolution has to happen server-side. That's the whole reason tenant sites relay browser requests through their own server routes rather than calling the gateway directly from the page — see post rendering below.
[!WARNING] When
getHostreturns an empty string, the client simply omits theX-Forwarded-Hostheader, and the gateway falls back to its own host. Depending on config that either fails tenant resolution (dropping the request) or resolves to the wrong tenant — so you don't get a clean error, you get ambiguous/empty results. Server-side, read inboundx-forwarded-hostfirst (Vercel, Cloudflare, and Amplify all set it), then fall back tohost. The token-only routes (confirmNewsletter,unsubscribeNewsletter) tolerate an empty host because they key off the token, not the host.
getIp and the visitor count
recordVisit does something subtle: it reads getIp again and puts the result in the request body as visitor_ip, not just in the X-Forwarded-For header. The reason is in the code comment — serverless runtimes (Fly machines, Amplify Lambda) strip X-Forwarded-For on outbound calls, so the header path silently loses the IP. Putting it in the body is the reliable path.
Without an IP, the day-rolling visitor_fingerprint column stays NULL and the Visitors KPI on the dashboard renders as "—". This only affects the fingerprint/Visitors number — a missing IP does not drop the visit itself (that's a host problem, not an IP problem). Server-side, try x-forwarded-for first, then platform headers (cf-connecting-ip on Cloudflare, x-real-ip on Vercel).
Read methods
These return content. Every GET goes through one internal helper with consistent behaviour:
- 404 →
null. A missing post/form/page resolves tonull, not an error. - Other non-2xx → throws
NukipaApiError({ message, status }), with the response body truncated to 200 chars in the message. - Response unwrapping: the helper returns
json.data ?? json ?? null, so both{ data: ... }and bare-payload responses work. - Caching: uses
defaultRevalidate(60s) unless the method overrides it. The list methods that return arrays coalescenullto[].
| Method | Returns | Endpoint | Notes |
|---|---|---|---|
getTenant() |
PublicTenant | null |
GET /public/v1/tenant |
The resolved tenant record. |
getTenantSeo() |
PublicTenantSeo | null |
GET /public/v1/tenant/seo |
IndexNow config for the /<key>.txt ownership check. null when SEO push isn't onboarded — 404 the key file in that case. |
listPosts({ limit?, offset?, folder? }) |
SlimPost[] |
GET /public/v1/posts |
Published posts. folder filters by folder slug. |
listFolders() |
PublicFolder[] |
GET /public/v1/folders |
Only folders that have at least one published post. |
getPostBySlug(slug) |
FullPost | null |
GET /public/v1/posts/:slug |
The full post — body HTML, components, sources. This is what you pass to <PostBody>. |
listRelatedPosts(slug, { limit? }) |
SlimPost[] |
GET /public/v1/posts/:slug/related |
Related posts for a given slug. |
getFormBySlug(slug) |
PublicForm | null |
GET /public/v1/forms/:slug |
The public form definition (fields, on-submit config). |
SlimPost is the listing shape (enough for cards/links); FullPost carries the rendered body and its component/source arrays. Both come from the generated types.
Headless-CMS reads (loosely typed)
These exist but their response types aren't in the generated set yet — they return NukipaPage, NukipaEvent, etc., which are Record<string, unknown> with a few known fields layered on top. Treat the extra fields as best-effort until the types narrow.
| Method | Returns | Endpoint | Notes |
|---|---|---|---|
listPages() |
NukipaPage[] |
GET /public/v1/pages |
Each page comes back with kind: 'raw' | 'schema'; render raw HTML or a schema-driven layout accordingly. |
getPageBySlug(slug) |
NukipaPage | null |
GET /public/v1/pages/:slug |
|
getPageSchema(id) |
Record<string, unknown> | null |
GET /public/v1/page-schemas/:id |
The schema definition for a schema-kind page. |
listEvents({ filter?, limit? }) |
NukipaEvent[] |
GET /public/v1/events |
Published only. filter: 'upcoming' | 'past' distinguishes the two listings. |
getEventBySlug(slug) |
NukipaEvent | null |
GET /public/v1/events/:slug |
|
getNav(kind) |
{ kind, tree, updated_at } | null |
GET /public/v1/nav?kind=... |
kind is 'header' or 'footer'. tree is a recursive nav structure. |
Newsletters
The publication card plus the subscribe flow. The /:slug and /subscribe calls forward the visitor host (so the publication lookup stays tenant-scoped). The confirm/unsubscribe routes key off the token, so an empty host is tolerated — but they still go through getJson, so the visitor headers (UA, Referer) are attached for attribution. They're not header-less; only the host is optional.
| Method | Returns | Endpoint | Notes |
|---|---|---|---|
getNewsletter(slug) |
NukipaNewsletterCard | null |
GET /public/v1/newsletters/:slug |
Card shape: name, description, optional subscribe_gate.fields, subscribe_redirect_url, double_opt_in. |
subscribeNewsletter(slug, input) |
NukipaNewsletterSubscribeResult | null |
POST .../subscribe |
See input/result below. |
confirmNewsletter(token) |
Record<string, unknown> | null |
GET .../confirm/:token |
Double-opt-in confirmation. Token-keyed; UA/Referer still attached. |
unsubscribeNewsletter(token) |
Record<string, unknown> | null |
GET .../unsubscribe/:token |
Token-keyed; UA/Referer still attached. |
The subscribe input requires consent: true as a literal — the gateway's schema rejects anything else, and the type forces it so omissions fail at compile time:
await client.subscribeNewsletter('product-updates', {
email: 'reader@example.com',
consent: true, // required literal `true`
custom_fields: { role: 'engineer' }, // matches subscribe_gate.fields
language: 'en' // optional preferred-delivery language
});
// → { id, status: 'pending' | 'active', requires_confirmation: boolean }
requires_confirmation reflects whether double-opt-in is on for the publication.
Audits (async lead-magnet flow)
The audit surface is an embedded lead-magnet: the visitor runs an audit against their own input (e.g. a URL), sees a teaser, and submits an email gate to get the full report. It's a small async state machine, not a single call. All four shapes are loosely typed (Record<string, unknown> with known fields) until the OpenAPI spec grows them.
| Method | Returns | Endpoint | Notes |
|---|---|---|---|
getAudit(slug) |
NukipaAuditCard | null |
GET /public/v1/audits/:slug |
The audit definition: name, description, input_schema, gate_form. |
runAudit(slug, { input }) |
NukipaAuditRun | null |
POST /public/v1/audits/:slug/run |
Enqueues a run and returns immediately with the run record (including its id). The body must be wrapped: { input: { … } } — the type enforces this so you can't pass a bare payload. |
getAuditRun(id) |
NukipaAuditRun | null |
GET /public/v1/audits/runs/:id |
The polling endpoint. Always uncached (cache: 'no-store'). |
submitAuditGate(id, input) |
NukipaAuditGateResult | null |
POST /public/v1/audits/runs/:id/gate |
Submits the email gate against a finished run — creates the CRM lead and triggers delivery of the full report. |
The run lifecycle is the part you have to handle. NukipaAuditRun.status is one of:
status |
Meaning |
|---|---|
queued |
Accepted, not started yet. |
running |
In progress. |
succeeded |
Finished — teaser / score are populated. |
failed |
The run errored. Surface a retry, don't poll forever. |
runAudit returns while the run is still queued — you do not get a finished result from it. Poll getAuditRun(id) every few seconds until status is succeeded or failed, then show the teaser and the gate form. Once the visitor submits the gate, call submitAuditGate(id, { email, consent? }).
const run = await client.runAudit('geo-audit', { input: { url: 'https://example.com' } });
let current = run;
while (current && (current.status === 'queued' || current.status === 'running')) {
await new Promise((r) => setTimeout(r, 3000));
current = await client.getAuditRun(run!.id);
}
if (current?.status === 'succeeded') {
// show current.teaser / current.score, then on gate submit:
await client.submitAuditGate(current.id, { email: 'reader@example.com', consent: true });
}
Write / signal methods
| Method | Returns | Endpoint | Behaviour |
|---|---|---|---|
submitForm(slug, body) |
FormSubmitResult |
POST /public/v1/forms/:slug/submit |
Submits a lead-gen form. Returns the submit result (coalesced to {} if the body was empty). |
submitContactForm(postId, body) |
Record<string, unknown> |
POST /public/v1/posts/:postId/contact-form-submissions |
Submits an inline contact form scoped to a post. |
recordVisit(input) |
VisitResult | null |
POST /public/v1/signals/visits |
Page-view ping. See below. |
recordCtaClick(input) |
void |
POST /public/v1/cta-clicks |
CTA click ping. See below. |
POSTs always use cache: 'no-store'. A 204 is treated as success with null data; other non-2xx throw NukipaApiError.
recordVisit
const result = await client.recordVisit({ path: '/blog/my-post' });
// VisitResult on HTTP 201, otherwise null
recordVisit is defensive on purpose:
- It re-reads
getIpand, if present, mergesvisitor_ipinto the body (the header-stripping workaround described above). - It returns the parsed result only when the response status is exactly
201. Any other status — including a200or204that the gateway considers a success — returnsnull. Anullhere is not necessarily a failure; it just means you didn't get a201body back. - The whole call is wrapped in try/catch and returns
nullon any thrown error. Analytics never throws.
You can also pass an optional client_nonce alongside the visit input — it rides in the body to correlate a cookieless proof-of-JS beacon.
recordCtaClick
await client.recordCtaClick({
cta_id: 'hero-cta',
cta_label: 'Start free',
cta_url: 'https://example.com/signup',
post_id: post.id,
page_path: '/blog/my-post'
});
recordCtaClick returns void and swallows every error — tracking is never on the critical path. The configured getReferer/getUserAgent resolvers are forwarded as headers on this SDK-direct call. In practice you rarely call this method directly: the post renderer's CTA island fires the relay for you on every CTA click — and that island path does not go through the SDK (see below).
[!TIP] Both signal methods degrade silently. A failed ping shows up as a gap in dashboard analytics, never as a broken page. That's the intended contract — don't add your own error handling expecting them to throw. (This applies to visits and CTA pings only. Forms do surface errors to the user — see the form components below.)
Post rendering: <PostBody>
@nukipa/post-renderer-react renders a CMS post body and then hydrates the interactive parts ("islands"). Server-side it injects the post's HTML directly via dangerouslySetInnerHTML; after mount, a useEffect walks the DOM for data-island="..." markers and attaches behaviour.
import { PostBody } from '@nukipa/post-renderer-react';
<PostBody
body={post.body}
components={post.components}
sources={post.sources}
postId={post.id}
lang={post.language}
/>
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
body |
string |
— (required) | The post body HTML from FullPost. |
components |
PostComponent[] |
[] |
Component data referenced by the body (forms, CTAs, carousels, etc.). |
sources |
PostSource[] |
[] |
Source/citation data, used to render the sources list. |
postId |
string | null |
null |
Attribution. Passed to form submissions and CTA pings so the platform knows which article the visitor was on. |
lang |
string |
— | Language hint forwarded into the underlying renderPostBody. |
endpoints |
Partial<IslandEndpoints> |
see below | Same-origin relay routes for form/CTA/contact-form POSTs. Merged over the defaults. |
className |
string |
'prose-body' |
Class on the wrapping <div>. |
[!WARNING] The
@nukipa/post-renderer-reactREADME is currently wrong aboutclient. Its example passesclient={client}, its hydrate table claims CTAs "click-track viaclient.recordCtaClick", and its "Passclient" section says omittingclientmakes islands "silently no-op." None of that matches the code.PostBodyPropshas noclientprop at all, and the islands POST to same-origin relay endpoints — they never touch the SDK client. Use the props in the table above; ignore the README'sclientinstructions until that doc is fixed.
Default relay endpoints
The islands can't call the gateway from the browser — CORS and the un-spoofable X-Forwarded-Host rule that out. Instead they POST to same-origin routes your host app owns, and those routes attach the host header server-side and forward to the gateway. The defaults:
const DEFAULT_ENDPOINTS = {
forms: '/api/forms',
ctaClicks: '/api/cta-clicks',
contactForms: '/api/contact-form-submissions'
};
| Endpoint | Method / body | Relays to |
|---|---|---|
forms |
GET ${forms}/:slug to load the form; POST ${forms} with { slug, post_id?, ...values } |
GET /public/v1/forms/:slug, POST /public/v1/forms/:slug/submit |
ctaClicks |
POST with { cta_id, cta_label, cta_url, post_id, page_path, referrer, user_agent } |
POST /public/v1/cta-clicks |
contactForms |
POST ${contactForms}/${postId} with { component_id, values } |
POST /public/v1/posts/:postId/contact-form-submissions |
You own these route handlers. The job of each one is small: resolve the tenant host server-side, attach X-Forwarded-Host, forward the body to the gateway, and never surface an error back to a tracking call. A real CTA relay handler looks like this:
export async function POST(req: Request) {
let payload = {};
try { payload = await req.json(); }
catch { return new Response(null, { status: 204 }); } // empty beacon body
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 { /* analytics gaps are fine; never block the click */ }
return new Response(null, { status: 204 });
}
[!NOTE] Note the provenance difference for CTAs. On the SDK-direct path,
recordCtaClickforwardsgetReferer/getUserAgentas request headers. The island path is different: it buildsreferrerfromdocument.referreranduser_agentfromnavigator.userAgentclient-side and puts them in the request body (see thectaClicksrow above). The island never invokes the SDK resolvers — sogetReferer/getUserAgentapply only to SDK-direct calls.
[!TIP] The defaults are just defaults. If your app mounts its relays elsewhere, pass an
endpointsoverride on<PostBody>. Use whatever paths fit your app — just keep them same-origin so the host header can be attached server-side.
Interactive islands
hydrateIslands returns a cleanup function (called from the useEffect teardown) that unbinds every listener and unmounts every React root. The islands:
| Island marker | What it does | If no JS |
|---|---|---|
data-island="cta" |
Attaches a click tracker to the existing <a data-cta-id>. Uses navigator.sendBeacon when available (survives page unload), else a keepalive fetch. The anchor is never replaced. |
The link's real href still navigates. Tracking is purely additive. |
data-island="form" |
Mounts <LeadForm> against data-form-slug. Reads optional data-form-title/description/submit-label. |
Falls back to nothing interactive; the static block still renders. |
data-island="contact-form" |
Mounts <ContactForm>. Reads the schema (title, description, submit_label, success_message, fields) from a JSON data-content attribute and data-component-id. Skipped entirely if postId is null — a contact submission must be attributed to a post. |
Static block renders; no form. |
data-island="carousel" |
Minimal prev/next. Toggles the active class on .bp-carousel__slide elements via [data-carousel-prev] / [data-carousel-next] buttons. No autoplay. |
Whichever slide is server-marked active shows; no controls. |
chart, widget |
Not hydrated. Placeholder HTML only. | Wire these per-tenant if you need them. |
The CTA design is hybrid on purpose: search engines and no-JS clients follow the real link; the tracker only runs after hydration. Lead and contact forms self-fetch (lead form) or self-contain (contact form) their definitions and post through the relays — they're self-contained once mounted.
<LeadForm> and <ContactForm> are also exported directly if you want to mount them outside a post body:
LeadFormGETs its definition from${endpoint}/:slug, seeds field values (checkboxes tofalse, everything else to''), and on submit POSTs{ slug, post_id?, ...values }. It honors aredirect_url(from the response or the form'son_submitconfig) and otherwise shows a success message. A submit error is surfaced to the user viadata?.error?.message.ContactFormis simpler: it takes itsfieldsas a prop and POSTs{ component_id, values }with no self-fetch. It surfaces submit errors the same way.
[!WARNING]
LeadFormhas a silent degraded state. Until its definition GET resolves it renderslead-form--loading, and the fetch's.catch(() => {})swallows failures — so if the relay GET fails, the form stays in the loading placeholder indefinitely with no error shown to the user. If a lead form looks stuck on its skeleton, check that theformsrelay'sGET ${forms}/:slugis reachable and returns the definition.
Styling
Static blocks use bp-* classes from @nukipa/post-content. Forms use lead-form* / contact-form* classes. Neither package ships the CSS — lift the scoped style block from apps/public/app/components/BlogArticle.vue into your tenant site.
FAQ
Do I need to pass all the resolvers?
Only gatewayUrl and getHost are required. Skip getIp and you lose the Visitors KPI (it renders "—"); skip getUserAgent/getReferer and those visit attributes are just absent. getSiteVersion is only for preview/workspace-daemon traffic. Everything else still works.
Why does my visit not show up even though the call succeeded?
recordVisit only returns a result on HTTP 201 and returns null otherwise — including on a 200/204 success. So a null return doesn't prove failure. If the visit genuinely isn't landing, the usual cause is host resolution: an empty getHost makes the gateway fall back to its own host and either drop the visit or attribute it to the wrong tenant. Check that the host resolver returns a real host server-side. If it's the Visitors number that's missing rather than the visit, wire getIp.
Can the browser call the gateway directly?
No. Gateway CORS uses an allowlist and X-Forwarded-Host isn't an allowed request header, so the browser can't set the tenant host. All form/CTA/visit traffic from the browser goes through your same-origin relay routes, which attach the host server-side.
What happens to forms and CTAs if JavaScript is disabled?
The static HTML renders correctly either way. CTAs keep working as plain links (tracking just doesn't fire). Forms won't be interactive — they only mount on hydration. Contact forms additionally require a non-null postId, or the island is skipped.
Is the audit flow synchronous?
No. runAudit enqueues and returns a queued run immediately; you poll getAuditRun(id) (uncached) until status is succeeded or failed. Only then do you show the teaser and the email gate (submitAuditGate). Don't expect a finished report from the runAudit call.
Are pages, events, newsletters, and audits production-ready?
The methods exist and work, but their response types are loose (Record<string, unknown> with a few known fields) until the OpenAPI spec grows those endpoints and the types are regenerated. Treat the extra fields as best-effort for now.