Steeren/The SDK & post rendering — referencelive from the platform← site
Get a site live

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 in src/types.gen.ts are 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 getHost returns an empty string, the client simply omits the X-Forwarded-Host header, 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 inbound x-forwarded-host first (Vercel, Cloudflare, and Amplify all set it), then fall back to host. 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 to null, 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 coalesce null to [].
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:

  1. It re-reads getIp and, if present, merges visitor_ip into the body (the header-stripping workaround described above).
  2. It returns the parsed result only when the response status is exactly 201. Any other status — including a 200 or 204 that the gateway considers a success — returns null. A null here is not necessarily a failure; it just means you didn't get a 201 body back.
  3. The whole call is wrapped in try/catch and returns null on 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-react README is currently wrong about client. Its example passes client={client}, its hydrate table claims CTAs "click-track via client.recordCtaClick", and its "Pass client" section says omitting client makes islands "silently no-op." None of that matches the code. PostBodyProps has no client prop 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's client instructions 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, recordCtaClick forwards getReferer/getUserAgent as request headers. The island path is different: it builds referrer from document.referrer and user_agent from navigator.userAgent client-side and puts them in the request body (see the ctaClicks row above). The island never invokes the SDK resolvers — so getReferer/getUserAgent apply only to SDK-direct calls.

[!TIP] The defaults are just defaults. If your app mounts its relays elsewhere, pass an endpoints override 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:

  • LeadForm GETs its definition from ${endpoint}/:slug, seeds field values (checkboxes to false, everything else to ''), and on submit POSTs { slug, post_id?, ...values }. It honors a redirect_url (from the response or the form's on_submit config) and otherwise shows a success message. A submit error is surfaced to the user via data?.error?.message.
  • ContactForm is simpler: it takes its fields as a prop and POSTs { component_id, values } with no self-fetch. It surfaces submit errors the same way.

[!WARNING] LeadForm has a silent degraded state. Until its definition GET resolves it renders lead-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 the forms relay's GET ${forms}/:slug is 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.

Served live from the platform · /docs/sdk-and-rendering-reference