Render Posts in Next.js or Astro
Complete examples for rendering Revnu posts on your own site with Next.js App Router or Astro, with canonical tags, JSON-LD, a sitemap that drops removed posts, new and edited posts appearing without a deploy, and fixing tables and links that render wrong.
Two complete examples. Both keep the token in a server-side environment variable and render on the server, which is the one hard rule. Both cover the four things that keep a headless blog healthy: the canonical tag, the JSON-LD, a sitemap that matches the list endpoint, and new and edited posts appearing without anyone deploying.
Not using Next.js or Astro?
If your site can render pages on the server (Nuxt, SvelteKit, Remix, a static site generator), the same calls work: see the Content API. If it's a single-page app built in Lovable, Vite or similar, or a site builder like Wix, use the Revnu-hosted blog instead. Posts fetched in the browser aren't reliably seen by search or AI engines.
Before you click I've Connected It
Deploy your site
Ship the code below to your live site, with
REVNU_CONTENT_API_KEYset to your current key.Load your live blog index once
Open the deployed blog index in a browser. That makes your server fetch the full post list with the current key.
Press I've Connected It
Back in Revnu, press I've Connected It on the Connect Your Site screen.
Warning
Until the live site has requested posts, the button is refused with "...hasn't requested any posts from Revnu yet". A test from your laptop also counts as a request, so check the deployed site, not a local build.
Next.js App Router
// lib/revnu.ts
const BASE = "https://revnu.com/api/content/v1";
const HEADERS = { Authorization: `Bearer ${process.env.REVNU_CONTENT_API_KEY}` };
// Re-fetch at most every 5 minutes; the revalidate route below can force it sooner.
const NEXT = { revalidate: 300, tags: ["revnu"] };
export async function getAllPosts() {
const posts = [];
let cursor: string | null = null;
do {
const url = `${BASE}/posts?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const r = await fetch(url, { headers: HEADERS, next: NEXT });
if (!r.ok) throw new Error(`revnu: ${r.status}`);
const page = await r.json();
posts.push(...page.posts);
cursor = page.cursor;
} while (cursor);
return posts;
}
export async function getPost(slug: string) {
const r = await fetch(`${BASE}/posts/${slug}`, { headers: HEADERS, next: NEXT });
if (r.status === 404) return null;
if (!r.ok) throw new Error(`revnu: ${r.status}`);
return r.json();
}// app/blog/[...slug]/page.tsx
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { getAllPosts, getPost } from "@/lib/revnu";
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((p) => ({ slug: p.canonicalPath.slice(1).split("/") }));
}
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const data = await getPost(slug[slug.length - 1]);
if (!data) return {};
const { headMeta } = data;
const og = headMeta.openGraph;
const tw = headMeta.twitter;
return {
title: headMeta.title,
description: headMeta.description,
alternates: { canonical: headMeta.canonical },
// Revnu sends one image object; Next.js wants an `images` array.
openGraph: {
type: "article",
url: og.url,
title: og.title,
description: og.description,
siteName: og.siteName,
publishedTime: og.publishedTime,
modifiedTime: og.modifiedTime,
authors: og.authors,
images: [og.image],
},
twitter: { card: tw.card, title: tw.title, description: tw.description, images: [tw.image.url] },
};
}
export default async function Post({ params }) {
const { slug } = await params;
const data = await getPost(slug[slug.length - 1]);
if (!data) notFound(); // removed or unpublished: serve a real 404
// One URL per post: a catch-all matches ANY path ending in the slug, and an old slug still resolves.
if (`/${slug.join("/")}` !== data.post.canonicalPath) {
permanentRedirect(`/blog${data.post.canonicalPath}`);
}
const html = data.post.body.html.replace(/href="\/(?!\/)/g, 'href="/blog/');
return (
<article>
{data.jsonLd.map((schema, i) => (
<script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
))}
<h1>{data.post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
</article>
);
}// app/blog/sitemap.ts, served at /blog/sitemap.xml
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/revnu";
export const revalidate = 300;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
// A post missing from the list was removed, so it drops out here too.
return posts.map((p) => ({
url: p.canonicalUrl,
lastModified: new Date(p.updatedAt ?? p.publishedAt),
}));
}Put the file in your blog's folder, so the sitemap sits at your blog address plus /sitemap.xml. That's the address Revnu submits to Google Search Console. Your site's main sitemap stays as it is; you can also list the blog sitemap in robots.txt. Your blog index page reads getAllPosts() the same way.
New and edited posts without a deploy
With revalidate: 300, new posts, edits and removals show up within about five minutes of the next visit. Nobody deploys. A post that isn't in generateStaticParams yet renders on its first request.
To refresh straight away, add a route and paste its URL, including the key, into Rebuild Hook in the Content API card on Visibility > Publishing Settings:
// app/api/revnu-revalidate/route.ts
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
// The ping is unsigned, so gate the route with a key of your own in the URL.
if (new URL(req.url).searchParams.get("key") !== process.env.REVNU_REVALIDATE_KEY) {
return new Response("forbidden", { status: 403 });
}
revalidateTag("revnu");
return Response.json({ ok: true });
}The hook URL then looks like https://yourdomain.com/api/revnu-revalidate?key=…. The route ignores the ping's body and only refreshes the cache; the posts themselves still come from the authenticated API.
Astro
---
// src/pages/blog/[...slug].astro
const BASE = "https://revnu.com/api/content/v1";
const HEADERS = { Authorization: `Bearer ${import.meta.env.REVNU_CONTENT_API_KEY}` };
export async function getStaticPaths() {
const posts = [];
let cursor = null;
do {
const r = await fetch(`${BASE}/posts?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, { headers: HEADERS });
if (!r.ok) throw new Error(`revnu: ${r.status}`);
const page = await r.json();
posts.push(...page.posts);
cursor = page.cursor;
} while (cursor);
return posts.map((p) => ({ params: { slug: p.canonicalPath.slice(1) } }));
}
const parts = String(Astro.params.slug).split("/");
const r = await fetch(`${BASE}/posts/${parts[parts.length - 1]}`, { headers: HEADERS });
if (!r.ok) throw new Error(`revnu: ${r.status}`);
const data = await r.json();
const html = data.post.body.html.replace(/href="\/(?!\/)/g, 'href="/blog/');
---
<html>
<head>
<title>{data.headMeta.title}</title>
<meta name="description" content={data.headMeta.description} />
<link rel="canonical" href={data.headMeta.canonical} />
{data.jsonLd.map((schema) => <script type="application/ld+json" set:html={JSON.stringify(schema)} />)}
</head>
<body><article><h1>{data.post.title}</h1><div set:html={html} /></article></body>
</html>// src/pages/blog/sitemap.xml.ts, served at /blog/sitemap.xml
export async function GET() {
const BASE = "https://revnu.com/api/content/v1";
const HEADERS = { Authorization: `Bearer ${import.meta.env.REVNU_CONTENT_API_KEY}` };
const posts = [];
let cursor = null;
do {
const r = await fetch(`${BASE}/posts?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, { headers: HEADERS });
if (!r.ok) throw new Error(`revnu: ${r.status}`);
const page = await r.json();
posts.push(...page.posts);
cursor = page.cursor;
} while (cursor);
const urls = posts
.map((p) => `<url><loc>${p.canonicalUrl}</loc><lastmod>${new Date(p.updatedAt ?? p.publishedAt).toISOString()}</lastmod></url>`)
.join("");
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, {
headers: { "Content-Type": "application/xml" },
});
}Keep it in your blog's folder, so it sits at your blog address plus /sitemap.xml. That's the address Revnu submits to Google Search Console.
New and edited posts on a static build
A static Astro build renders once per deploy, so new posts, edits and removals only appear after a rebuild. Create a deploy hook on your host and paste it into Rebuild Hook in the Content API card on Visibility > Publishing Settings. Revnu POSTs to it on every publish, update and removal, and your host rebuilds. A removed post is no longer in the list, so the rebuild drops its page and its sitemap entry together.
Tables and links look wrong
Each post comes in two forms: body.markdown, which is GitHub-flavoured markdown (GFM), and body.html, which Revnu has already rendered with tables, lists and links. When something looks broken on your site, the cause is almost always on the rendering side:
Tables show as rows of pipes (
| a | b |). You're renderingbody.markdownwith a renderer that doesn't support GFM tables. Turn on GFM (for exampleremark-gfm, orgfm: trueinmarked), or renderbody.htmlinstead.Tables render but have no borders or spacing.
body.htmlcontains a plain<table>. Your site's CSS needs table styles for the blog container.Links between posts 404. Links in
body.htmlare root-relative (/slug). If your blog lives under/blog, rewrite them as the examples above do.Links show as text in brackets. Same cause as the tables: render
body.html, or use a GFM renderer.
If body.html itself looks wrong, for example a table that's missing in the HTML Revnu returns, email support@revnu.com with the post's slug, what you expected and a screenshot of how it renders.
Frequently Asked Questions
Do I need to handle the redirect for non-canonical paths?
Yes, if you use a catch-all route. Without it every post is reachable at infinitely many paths, which splits ranking. Compare the requested path with canonicalPath and redirect permanently. This also handles a post whose URL was changed in Revnu: the old slug still returns the post, and the redirect sends visitors to the new address.
How do I keep removed posts off my site?
Treat the list endpoint as the truth. A post that's missing from it was removed: return 404 for its page and leave it out of the sitemap. Both examples do this.
What about robots.txt?
Serve a robots.txt that allows crawling of the blog and of your build assets, and points at your sitemaps, including the blog one.
Do edited posts need a redeploy?
Not on Next.js with revalidate: edits appear within the revalidate window, or straight away with the revalidate route. A static build needs the rebuild hook.
Why do tables in my posts look broken?
Your renderer is reading body.markdown without GitHub-flavoured markdown turned on, or your CSS has no table styles. Render body.html, or enable GFM tables in your markdown renderer.
Which JSON-LD should I embed?
Every entry in jsonLd, each in its own <script type="application/ld+json">. That's the Article, BreadcrumbList and, when the post has FAQs, FAQPage data the hosted blog ships.