npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rankchat/next

v0.5.1

Published

Typed client, webhook revalidation handler and scaffolder for publishing Rankchat blog posts to your own site.

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 /blog after the webhook lands, /blog is 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 hour

With 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 init

That 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 need

It 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 alone

The 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_KEY is a secret and must never carry a NEXT_PUBLIC_ prefix. NEXT_PUBLIC_RANKCHAT_SITE_KEY is 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:

  1. Read the raw body as bytes or text, before any JSON parsing.
  2. Compute HMAC-SHA256(rawBody, secret) and hex-encode it, lowercase.
  3. Compare "sha256=" + hex with the header using a constant-time comparison.
  4. Reject if X-Rankchat-Timestamp is more than 300 seconds from your clock.
  5. Reject if the body's timestamp is more than 300 seconds from your clock.
  6. 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:sdk

test/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.