@rankchat/next
v0.5.1
Published
Typed client, webhook revalidation handler and scaffolder for publishing Rankchat blog posts to your own site.
Maintainers
Readme
@rankchat/next
Publish blog posts from Rankchat to a site you built yourself.
Rankchat writes the post. Your site fetches it over a normal HTTPS API and renders it with your own components, your own layout, your own domain. Rankchat never touches your code and never needs credentials to your host.
This package is a convenience, not a requirement. The API is bearer-token HTTPS returning JSON. If you are not on Next.js, skip to Any other stack and call it directly.
Read this first: revalidation is lazy
This is the single thing people get wrong, so it is at the top rather than buried in an FAQ.
When Rankchat tells your site that a post changed, the webhook handler calls Next.js's
revalidateTag. revalidateTag does not rebuild anything. It marks the cached pages
carrying that tag as stale. The page is regenerated on the next request for it, and
depending on your configuration the very next visitor may still be served the stale copy while
the fresh one is built behind them.
So:
- A 200 from your revalidate route means "accepted, cache marked stale".
- It does not mean "the new post is live right now".
- If nobody visits
/blogafter the webhook lands,/blogis not rebuilt. That is correct behaviour, not a bug. - Refreshing your browser once and seeing the old content is normal. Refresh again.
The time-based fallback, which is not optional
A webhook can be missed. Your site can be mid-deploy, your endpoint can be briefly down, the secret can have been rotated on one side and not the other. Rankchat retries five times over roughly an hour and then stops and marks the connection as failing on your dashboard.
Always keep a time-based revalidate on your content pages as well:
// app/blog/page.tsx and app/blog/[slug]/page.tsx
export const revalidate = 3600; // one hourWith both in place, the webhook makes updates fast and the timer makes them certain. A missed delivery costs you up to an hour of staleness instead of staying wrong until your next deploy. Pick the number you can live with as a worst case: 300 for a news site, 3600 for most, 86400 if your content genuinely never changes after publication.
If you cannot accept any staleness at all, do not use ISR for these pages. Render them dynamically and accept the latency instead. Do not try to make revalidation eager; it is not.
Install
npm install @rankchat/next
npx @rankchat/next initThat is the whole setup. init detects your project layout (src/app or app, TypeScript or
JavaScript, @/* alias or relative imports) and writes:
lib/rankchat.ts the client
app/blog/page.tsx the index
app/blog/[slug]/page.tsx one post, with metadata and JSON-LD
app/blog/sitemap.ts the sitemap
app/api/rankchat/revalidate/route.ts the webhook receiver
.env.local.example the two keys you needIt also edits one file it did not create, your root layout, to import the stylesheet and render
the chat widget. That edit is idempotent and refuses rather than guessing if it cannot find a
</body> to anchor to. Pass --no-layout to do it yourself.
Existing files are never overwritten without --force. The only thing left for you is adding
your keys.
npx @rankchat/next init --help # options
npx @rankchat/next init --blog-route /insights
npx @rankchat/next init --no-layout # leave my layout aloneThe generated files are yours. Restyle them, delete the comments, move them. Nothing in this package reaches back into them.
Requires Node 18.17 or later. next is an optional peer dependency, needed only for
createRevalidateHandler and the helpers that return Next types.
Post styling
content_html arrives with no class names on any element, so without a stylesheet it renders as
unstyled text. init adds this to your root layout for you:
import '@rankchat/next/styles.css';It styles one class, .rankchat-prose, and sets no fonts or colours of its own so it inherits
your site. Restyle it with custom properties rather than by fighting selectors:
.rankchat-prose {
--rankchat-prose-accent: #7e611c;
--rankchat-prose-measure: 68ch;
}Or skip it entirely and style .rankchat-prose yourself.
Leads chat widget
init writes components/RankchatWidget.tsx and renders it in your root layout for you:
import { RankchatWidget } from '@/components/RankchatWidget';
<RankchatWidget />There is no key to copy. It is fetched with the API key you already have, the same way the Rankchat WordPress plugin does, so rotating the widget key heals on the next render instead of leaving a dead chat bubble on a live site. It renders nothing when Leads has never been set up or when the widget is switched off in the dashboard, and never throws.
To skip the round trip, set NEXT_PUBLIC_RANKCHAT_SITE_KEY and it is used directly.
Two keys, opposite rules.
RANKCHAT_API_KEYis a secret and must never carry aNEXT_PUBLIC_prefix.NEXT_PUBLIC_RANKCHAT_SITE_KEYis public by design: it ships in your page HTML either way, which is what makes the widget work. Do not swap them.
Images
Featured images are served from more than one host, so next/image rejects them until you list
those hosts. The scaffolder generates a plain <img>, which works everywhere and needs no
configuration. To use next/image instead, add this to next.config:
images: {
remotePatterns: [
{ protocol: 'https', hostname: '*.supabase.co' },
{ protocol: 'https', hostname: 'image-worker-production.up.railway.app' },
],
},Do not use hostname: '**' to avoid the list. That turns your site into an open image proxy for
the entire internet.
This list is specific to Rankchat's current infrastructure and will be replaced by a single
Rankchat-controlled hostname. Until then, a plain <img> is the lower-maintenance choice.
Environment
Connect your site on the Integrations page in Rankchat. You will be shown an API key and a webhook secret exactly once. Put both in your server environment:
RANKCHAT_API_KEY=rc_live_...
RANKCHAT_WEBHOOK_SECRET=whsec_...Never prefix either of these with NEXT_PUBLIC_
Next.js compiles every NEXT_PUBLIC_ variable into the JavaScript it serves to browsers. The
API key reads every published post on your website; the webhook secret is the only thing
stopping anyone from forcing rebuilds of your site. A NEXT_PUBLIC_ key is a key that is public
forever, and the only fix is to rotate it.
The SDK refuses to start if it finds NEXT_PUBLIC_RANKCHAT_API_KEY set, and it throws if it is
called in a browser. Both are deliberate. If you hit either, the fix is to move the fetch into a
Server Component, a route handler or a server action, and pass the data down as props.
Lost the key? You cannot recover it. Rotate it in Rankchat. The old key keeps working for 24 hours so a rotation never takes your site down mid-deploy.
The Next.js path, by hand
You do not need this section if you ran npx @rankchat/next init, which writes all of it. It is
here for people who want to see what the scaffolder produces before running it, or who are
wiring things into an existing blog rather than starting a new one.
The helpers in @rankchat/next/helpers do the fiddly parts and are worth using even when you
write the pages yourself:
| Helper | What it saves you |
|---|---|
| listPostsSafe / listAllPostsSafe / getPostSafe | an unreachable API returning [] instead of failing your whole build |
| buildPostMetadata(post, { fallbackUrl }) | canonical from the frozen post.url, OG article tags, noindex when the post is gone |
| buildPostJsonLd(post, canonical) | Rankchat's own schema when present, a BlogPosting fallback when not |
| createBlogSitemap(client, { siteUrl }) | cursor pagination, and URLs that agree with your canonicals |
| postSlugs(posts) | filtering the nullable slug before it reaches generateStaticParams |
| formatPostDate(value) | a pinned locale, so server and browser render the same date |
1. One client for the whole site
// lib/rankchat.ts SERVER ONLY
import { createRankchatClient } from '@rankchat/next';
export const rankchat = createRankchatClient({
revalidate: 3600, // the time-based fallback described above
});2. List page
// app/blog/page.tsx
import { rankchat } from '@/lib/rankchat';
export const revalidate = 3600;
export default async function BlogIndex() {
const { posts } = await rankchat.listPosts({ limit: 20 });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<p>{post.excerpt}</p>
</li>
))}
</ul>
);
}3. Post page
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { rankchat } from '@/lib/rankchat';
export const revalidate = 3600;
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await rankchat.getPost(slug);
if (!post) notFound(); // deleted or unpublished: a real 404, not a 200 saying "not found"
return <article dangerouslySetInnerHTML={{ __html: post.content_html ?? '' }} />;
}getPost returns null for a slug that does not exist and for one that exists but is not
published. The two are not distinguishable, on purpose: your unreleased content calendar is not
something a competitor should be able to enumerate through response codes.
4. Revalidate route
// app/api/rankchat/revalidate/route.ts
import { createRevalidateHandler } from '@rankchat/next';
export const dynamic = 'force-dynamic';
export const POST = createRevalidateHandler({
secret: process.env.RANKCHAT_WEBHOOK_SECRET,
});Register https://your-site.com/api/rankchat/revalidate as the webhook URL in Rankchat.
That is the whole integration. For a full runnable version, including generateMetadata with a
canonical URL, BlogPosting JSON-LD and a sitemap, run npx @rankchat/next init and read what
it writes. It generates exactly this, wired to your own project layout.
API
createRankchatClient(options?)
| Option | Default | Notes |
|---|---|---|
| apiKey | process.env.RANKCHAT_API_KEY | Server-side only. |
| baseUrl | process.env.RANKCHAT_API_URL, then https://www.rankchat.ai | Your Integrations page shows the exact base URL. |
| revalidate | none | Default next.revalidate for every request, in seconds. |
| tags | none | Cache tags added to every request. |
| timeoutMs | none | See the caching caveat below. |
| fetch | global fetch | Injectable for tests. |
timeoutMs and caching. Next.js does not cache a fetch that carries an AbortSignal.
Setting timeoutMs attaches one, which silently turns your ISR pages dynamic. It is off by
default for that reason. Set it only when you want a bounded request and are willing to lose the
framework cache on that call.
client.listPosts(options?)
const { posts, meta } = await rankchat.listPosts({ limit: 20, cursor: null });
// meta: { next_cursor: string | null, has_more: boolean }Newest first. limit defaults to 20 and is capped at 100 by the API (out-of-range values are
clamped, not rejected). Pass meta.next_cursor back as cursor for the next page. List items
carry excerpt but not the post body.
client.listAllPosts(options?)
Follows the cursor and returns every published post. Use it for sitemaps and
generateStaticParams. Stops at 100 pages as a safety ceiling.
client.getPost(slug, options?)
Returns the post including content_html and schema_markup, or null on 404.
Any other non-2xx throws RankchatApiError, which carries status, code (one of
MALFORMED_KEY, INVALID_KEY, POST_NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR, UNKNOWN)
and retryAfterSeconds on a 429. Branch on code, never on the message.
Rate limit: 600 requests per hour per key. A full site build of a thousand posts fits comfortably
if you use listAllPosts (which pages at 100) rather than fetching each post individually.
createRevalidateHandler(options?)
| Option | Default | Notes |
|---|---|---|
| secret | process.env.RANKCHAT_WEBHOOK_SECRET | Required, one way or the other. |
| tags | none | Extra tags, or a function of the payload. Added to the defaults, never replacing them. |
| paths | none | Extra paths for revalidatePath. |
| toleranceSeconds | 300 | Clock skew allowance. |
| onEvent | none | Runs after verification. Throwing produces a 500, which Rankchat retries. |
Always invalidates rankchat:posts and rankchat:post:<slug>, which are the tags the client
sets on its own requests. That is why the four-step setup above needs no tag configuration.
Responses:
| Status | Meaning | Rankchat's reaction |
|---|---|---|
| 200 | Accepted, cache marked stale | Done |
| 400 | Signature valid, body was not a payload | Retries |
| 401 | Signature, timestamp or secret wrong | Retries, then marks the connection as failing |
| 405 | Not a POST | Retries |
| 500 | Your onEvent threw | Retries |
The 401 body is identical for every failure reason and never says which check failed.
verifyWebhookSignature(args)
The verifier on its own, for when you are not using the handler.
import { verifyWebhookSignature } from '@rankchat/next/webhook';
const result = await verifyWebhookSignature({
rawBody, // the exact string you received
secret: process.env.RANKCHAT_WEBHOOK_SECRET!,
signatureHeader: req.headers.get('X-Rankchat-Signature'),
timestampHeader: req.headers.get('X-Rankchat-Timestamp'),
});
// { valid: boolean, reason: 'ok' | 'signature_mismatch' | ... }@rankchat/next/webhook imports nothing at all: no next, no Node builtins, no dependencies. It
runs on Node, Deno, Bun, Cloudflare Workers and the Edge runtime.
Types
Every wire shape is exported so you never redeclare one: RankchatPost, RankchatPostSummary,
RankchatListResponse, RankchatPostResponse, RankchatErrorCode, RankchatWebhookPayload,
RankchatWebhookEvent, RankchatWebhookPost, CmsPlatform, CustomPlatformConfig.
Every field the API can return empty is typed | null, not optional. The key is always present;
the value is what varies.
Any other stack
The API is two GET endpoints and one POST webhook. Nothing here is Next.js specific.
Fetch a list
curl https://www.rankchat.ai/api/content/v1/posts?limit=20 \
-H "Authorization: Bearer $RANKCHAT_API_KEY"{
"data": [
{
"id": "uuid",
"slug": "how-to-choose-a-roofer",
"title": "How to choose a roofer",
"excerpt": "Plain text, about 200 characters.",
"meta_title": "How to choose a roofer",
"meta_description": "...",
"featured_image_url": "https://...",
"featured_image_alt": "...",
"published_at": "2026-07-29T12:00:00.000Z",
"updated_at": "2026-07-29T12:00:00.000Z",
"url": "https://example.com/blog/how-to-choose-a-roofer"
}
],
"meta": { "next_cursor": "eyJwIjoi...", "has_more": true },
"error": null
}Paginate by passing meta.next_cursor back as ?cursor=. Keep going while has_more is true.
Fetch one post
curl https://www.rankchat.ai/api/content/v1/posts/how-to-choose-a-roofer \
-H "Authorization: Bearer $RANKCHAT_API_KEY"Same shape plus content_html, content_markdown (currently always null) and schema_markup.
Errors
| Status | error.code | Meaning |
|---|---|---|
| 400 | MALFORMED_KEY | The header is missing or not Authorization: Bearer rc_live_.... |
| 401 | INVALID_KEY | Unknown, revoked, or past the 24 hour rotation grace. |
| 404 | POST_NOT_FOUND | No published post with that slug on your website. |
| 429 | RATE_LIMITED | 600 per hour per key. Honour Retry-After. |
| 500 | INTERNAL_ERROR | Retry shortly. |
Caching
Both endpoints send a strong ETag and honour If-None-Match with a 304. Store the ETag and
send it back; an unchanged list costs you a round trip and no body.
Responses are Cache-Control: private, max-age=0, must-revalidate and Vary: Authorization.
Do not put them behind a shared cache that ignores Vary.
Verifying a webhook without this package
Rankchat POSTs JSON with these headers:
| Header | Meaning |
|---|---|
| X-Rankchat-Signature | sha256=<hex>, HMAC-SHA256 of the raw request body using your webhook secret |
| X-Rankchat-Timestamp | Unix seconds |
| X-Rankchat-Event | post.published, post.updated or post.deleted |
| X-Rankchat-Delivery | Idempotency key, stable across every retry of one event |
Body:
{
"event": "post.published",
"timestamp": "2026-07-29T12:00:00.000Z",
"website_id": "uuid",
"post": {
"id": "uuid",
"slug": "how-to-choose-a-roofer",
"path": "/blog/how-to-choose-a-roofer",
"url": "https://example.com/blog/how-to-choose-a-roofer"
}
}slug, path and url are present on every event, deletes included, so you can always name the
route to invalidate.
To verify, in any language:
- Read the raw body as bytes or text, before any JSON parsing.
- Compute
HMAC-SHA256(rawBody, secret)and hex-encode it, lowercase. - Compare
"sha256=" + hexwith the header using a constant-time comparison. - Reject if
X-Rankchat-Timestampis more than 300 seconds from your clock. - Reject if the body's
timestampis more than 300 seconds from your clock. - Answer any 2xx to accept. Answer 401 for every failure, without saying which check failed.
Step 1 is the one that catches people out. If you let a body parser hand you an object and then
re-serialize it to verify, key order, unicode escaping and number formatting will all differ from
what Rankchat signed, and the signature will never match. In Express, use
express.raw({ type: 'application/json' }) on this route only.
Step 5 is the one that actually closes replay. The header timestamp is not covered by the
signature and so is forgeable; the body's copy is signed. The body is re-stamped on every
delivery attempt, which is why the signature changes per attempt while X-Rankchat-Delivery
stays the same. Deduplicate on the delivery id, never on a timestamp.
Node example:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestampHeader));
if (!Number.isFinite(skew) || skew > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length) return false;
if (!timingSafeEqual(a, b)) return false;
const bodyTs = Date.parse(JSON.parse(rawBody).timestamp);
return Number.isFinite(bodyTs) && Math.abs(Date.now() - bodyTs) <= 300_000;
}Everything above applies equally without webhooks: polling the list endpoint on a schedule is a complete, supported integration. The webhook only makes updates fast.
Troubleshooting
Every webhook returns 401. The secret does not match. Check for a trailing newline in your
environment file, and check you did not paste the API key (rc_live_) where the webhook secret
(whsec_) belongs. If you rotated recently, the new secret is the one to use.
Webhooks verify but nothing changes on the site. Expected. Revalidation is lazy. Request the page a second time. If it still does not change, the page probably is not tagged: it must fetch through the SDK client, or you must add its tag or path to the handler options.
RANKCHAT_API_KEY is not set during a build. Build-time env vars are separate from runtime
ones on most hosts. Set it in both.
RankchatApiError with code INVALID_KEY right after a rotation. The old key stays valid
for 24 hours, so this means the new key was not deployed. Redeploy with the new value.
429 during a large build. Use listAllPosts, which pages at 100, rather than one request per
post. 600 requests per hour per key.
Development
npm run build # emit dist/
npm run typecheck
npm test # from the repository root: npm run test:sdktest/webhook-parity.test.ts imports Rankchat's real signer from
supabase/functions/shared/webhook-signing.ts, signs with it, and verifies with this package's
verifier. Two copies of an HMAC algorithm in two runtimes is exactly the situation where a
difference goes unnoticed until every customer's revalidation silently 401s, so that test is the
contract between them. If you change either file, it is what must still pass.
This package is marked private in package.json. Publishing is a deliberate manual step, not
something a merge should ever do. See docs/GO_LIVE.md.
