Next.js guide

Show your Ryterr posts on a Next.js blog.

Read your Ryterr posts from the Content API in a Next.js App Router site, cache them with ISR, and refresh a page the moment a post is published with a signed webhook.

How the pieces fit.

Your /blog reads posts from the Content API and caches them with ISR. A webhook route refreshes the affected pages when a post is published.

  1. 1

    Ryterr publishes

    A scheduled run or your own press of Publish finishes a post.

  2. 2

    Your receiver hears it

    Ryterr sends a signed post.published event to your webhook URL.

  3. 3

    Next.js refreshes

    The receiver calls revalidatePath for the blog index and the post.

  4. 4

    Your blog is live

    The next visitor sees the new post. Without the webhook, ISR picks it up within 60 seconds.

1

Create an API key

In Ryterr, open Settings, then API keys, and press Create key.

For this guide choose Webhook. Add your receiver URL, create the key, then copy the key into your site as RYTERR_API_KEY. Ryterr shows the key once, so copy it before you close the dialog. The same key reads posts through the Content API and verifies the webhook signature for this connection.

If your site only polls and does not need an instant refresh, choose Content API instead. If you lose a key, use Rotate to issue a new one. The old value stops working at once.

2

Install the SDK

@ryterr/client gives you TypeScript types for the Content API and the webhook verify helper. Plain fetch works too.

terminalShell
pnpm add @ryterr/client
# or npm install @ryterr/client
# or yarn add @ryterr/client
Set it up with an AI agent

Paste this into Cursor, Claude, v0 or any coding agent. It writes the receiver, the environment variables and the fetch code for you.

ryterr-setup-prompt.mdmarkdown
You are an expert Next.js (App Router) developer. Integrate Ryterr (https://ryterr.com), an AI blog platform that auto-publishes research-backed, fact-checked posts, into the user's site so new posts appear on /blog automatically: instantly via webhook, with the Content API as the on-demand source.

Dashboard setup (have the user do this once):
- Ryterr -> Settings -> API keys -> New API key -> choose "Webhook". Set the receiver URL to https://THEIR-SITE/api/ryterr-webhook. Copy the key (shown once) into env as RYTERR_API_KEY. The same key also authenticates the Content API.

Env (.env.local and production):
  RYTERR_API_KEY=rytk_live_...
  RYTERR_API_BASE=https://ryterr.com

Create these files (output complete, production-ready code with error handling):
1. app/api/ryterr-webhook/route.ts - call verifyWebhook(req, process.env.RYTERR_API_KEY) from "@ryterr/client". On post.published / post.updated / post.deleted, revalidatePath("/blog") and the affected /blog/SLUG. Return 401 on InvalidSignatureError.
2. app/blog/page.tsx - fetch RYTERR_API_BASE + "/api/v1/content/posts?limit=20" with header "Authorization: Bearer " + RYTERR_API_KEY, set "export const revalidate = 60", and list the posts (response shape: { data: [...] }).
3. app/blog/[slug]/page.tsx - fetch "/api/v1/content/posts/" + slug. Render post.html (already sanitized server-side by Ryterr) with dangerouslySetInnerHTML, featuredImageUrl above it. 404 when missing.

Install: pnpm add @ryterr/client  (or use plain fetch + Web Crypto for zero dependencies).

Verify: use Ryterr's "Send test event" button on the connection and confirm the receiver returns 2xx and the page revalidates.

Use only the public Content API contract: cursor-paginated list, single post by slug or id, payload has sanitized html + markdown + metadata. Full guide: https://ryterr.com/integrations/nextjs
3

Add the key to your environment

Check that .env.local is in your .gitignore, then add the key.

.env.localEnv
RYTERR_API_KEY=rytk_live_...
RYTERR_API_BASE=https://ryterr.com
4

Build the blog index

Fetch the posts published to this connection. ISR caches the page for 60 seconds, and the webhook in step 6 clears that cache on demand.

app/blog/page.tsxTypeScript
// app/blog/page.tsx
const RYTERR_API_BASE = process.env.RYTERR_API_BASE ?? "https://ryterr.com"

// ISR: refresh every 60 seconds. The webhook below makes this
// instant when it fires successfully.
export const revalidate = 60

export default async function BlogIndex() {
  const res = await fetch(`${RYTERR_API_BASE}/api/v1/content/posts?limit=20`, {
    headers: {
      Authorization: `Bearer ${process.env.RYTERR_API_KEY!}`,
    },
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error("Failed to load Ryterr posts")

  const { data: posts } = await res.json()

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            <a href={`/blog/${p.slug}`}>{p.title}</a>
            <p>{p.excerpt}</p>
          </li>
        ))}
      </ul>
    </main>
  )
}
5

Build the post page

Fetch one post by slug. post.html holds the body with its inline images. post.body is the Markdown if you would rather render it yourself.

app/blog/[slug]/page.tsxTypeScript
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation"

const RYTERR_API_BASE = process.env.RYTERR_API_BASE ?? "https://ryterr.com"

export const revalidate = 60
export const dynamicParams = true

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const res = await fetch(
    `${RYTERR_API_BASE}/api/v1/content/posts/${encodeURIComponent(slug)}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.RYTERR_API_KEY!}`,
      },
      next: { revalidate: 60 },
    }
  )
  if (res.status === 404) notFound()
  if (!res.ok) throw new Error("Failed to load Ryterr post")

  const { data: post } = await res.json()
  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      {post.featuredImageUrl ? (
        <img
          src={post.featuredImageUrl}
          alt={post.featuredImageAlt ?? post.title}
        />
      ) : null}
      {/* post.html is sanitized server-side by Ryterr (scripts, on*
          handlers, and javascript: URLs are stripped), so it is safe
          to render directly. Add your own sanitizer for defense in
          depth if you like. */}
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}
Ryterr sanitizes post.html before it sends it. Script tags, inline event handlers and javascript: URLs are removed, so it is safe to render directly. Run it through your own sanitizer as well if you want a second layer.
6

Add the webhook receiver

Without a receiver, a new post takes up to 60 seconds to appear, which is the ISR window. With one, Ryterr sends a signed request the moment a post is published and your blog refreshes on the next visit.

app/api/ryterr-webhook/route.tsTypeScript
// app/api/ryterr-webhook/route.ts
import { verifyWebhook, InvalidSignatureError } from "@ryterr/client"
import { revalidatePath } from "next/cache"

export async function POST(req: Request) {
  try {
    const event = await verifyWebhook(req, process.env.RYTERR_API_KEY!)

    if (event.type === "post.published") {
      revalidatePath("/blog")
      revalidatePath(`/blog/${event.data.post.slug}`)
    }
    if (event.type === "post.updated") {
      revalidatePath("/blog")
      revalidatePath(`/blog/${event.data.post.slug}`)
      if (event.data.previousSlug) {
        revalidatePath(`/blog/${event.data.previousSlug}`)
      }
    }
    if (event.type === "post.deleted") {
      revalidatePath("/blog")
      revalidatePath(`/blog/${event.data.slug}`)
    }

    return Response.json({ ok: true })
  } catch (err) {
    if (err instanceof InvalidSignatureError) {
      return new Response("bad signature", { status: 401 })
    }
    throw err
  }
}
Point your Webhook key at https://your-site.com/api/ryterr-webhook and use the same key in your environment. Then open Connections, expand the connection and press Send test event. If the receiver answers 200, it is ready for real posts.
7

Deploy and schedule

Ship your site anywhere Next.js runs, such as Vercel, Netlify or your own server.

In Ryterr, open Schedules and create a schedule that publishes to your webhook connection. Pick a cadence and your topics. Schedules are part of the Solo plan. A post flagged for review waits for you instead of publishing on its own.

What Ryterr guarantees.

The details that keep your blog in step with your Ryterr account, taken from the delivery and Content API code.

Retries

A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.

Signed requests

Each request carries an HMAC-SHA256 signature. verifyWebhook rejects a timestamp more than 5 minutes old.

One id per event

An event keeps the same id on every retry, so your receiver can ignore a repeat.

10 seconds per attempt

Each attempt waits up to 10 seconds for your answer before it counts as failed.

Newest wins

A newer delivery for the same post replaces an older one that is still waiting.

A notice if it gives up

After the sixth failed attempt Ryterr stops and sends you a notification.

Rate limit

The Content API allows 100 requests a minute per key and answers 429 with a Retry-After header beyond that.

Cache header

Content API responses carry Cache-Control: private, max-age=60, so cache them in your own app.

Questions from developers

Something else on your mind? Write to us.

Do I have to use the SDK?

No. The code above uses plain fetch for reads. The SDK adds types and verifyWebhook, and you can check the signature yourself with Web Crypto if you want no dependency.

Where does it run?

@ryterr/client needs Node 18.17 or later, Bun, or any runtime with fetch and crypto.subtle, including Cloudflare Workers and Deno Deploy.

Can I test the receiver before a real post?

Yes. Open Connections, expand your webhook connection and press Send test event. Ryterr sends one signed webhook.test event and shows the HTTP status. It is never retried, and your receiver should answer 200 without publishing anything.

What if I unpublish a post?

The Content API stops returning it for that connection, and the webhook sends post.deleted so you can refresh the blog and drop the page.

Write the post your blog will show.

Two posts free, no card needed. Wire the finished post into this guide when you are ready.