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
Ryterr publishes
A scheduled run or your own press of Publish finishes a post.
- 2
Your receiver hears it
Ryterr sends a signed post.published event to your webhook URL.
- 3
Next.js refreshes
The receiver calls revalidatePath for the blog index and the post.
- 4
Your blog is live
The next visitor sees the new post. Without the webhook, ISR picks it up within 60 seconds.
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.
Install the SDK
@ryterr/client gives you TypeScript types for the Content API and the webhook verify helper. Plain fetch works too.
pnpm add @ryterr/client
# or npm install @ryterr/client
# or yarn add @ryterr/clientSet 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.
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/nextjsAdd the key to your environment
Check that .env.local is in your .gitignore, then add the key.
RYTERR_API_KEY=rytk_live_...
RYTERR_API_BASE=https://ryterr.comBuild 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.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>
)
}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.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>
)
}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.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.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
}
}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.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?
Where does it run?
Can I test the receiver before a real post?
What if I unpublish a post?

Write the post your blog will show.
Two posts free, no card needed. Wire the finished post into this guide when you are ready.

