Content API / Render Posts in Next.js or Astro
Render Posts in Next.js or Astro
Complete examples for rendering Revnu posts on your own site with Next.js App Router or Astro, plus how updates reach a build-time site.
Two complete examples. Both keep the token in a server-side environment variable and render on the server, which is the one hard rule.
Next.js App Router
// lib/revnu.ts
const BASE = "https://revnu.com/api/content/v1";
const HEADERS = { Authorization: `Bearer ${process.env.REVNU_TOKEN}` };
export async function getPosts() {
const r = await fetch(`${BASE}/posts`, { headers: HEADERS, next: { revalidate: 300 } });
if (!r.ok) throw new Error(`revnu: ${r.status}`);
return r.json();
}
export async function getPost(slug: string) {
const r = await fetch(`${BASE}/posts/${slug}`, { headers: HEADERS, next: { revalidate: 300 } });
if (r.status === 404) return null;
if (!r.ok) throw new Error(`revnu: ${r.status}`);
return r.json();
}// app/blog/[...slug]/page.tsx
import { notFound, permanentRedirect } from "next/navigation";
import { getPost, getPosts } from "@/lib/revnu";
export async function generateStaticParams() {
const { posts } = await getPosts();
return posts.map((p) => ({ slug: p.canonicalPath.slice(1).split("/") }));
}
export default async function Post({ params }) {
const { slug } = await params;
const data = await getPost(slug[slug.length - 1]);
if (!data) notFound();
// One URL per post: a catch-all matches ANY path ending in the slug.
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>
);
}With revalidate set, new posts appear within minutes and you need nothing else.
Astro
---
// src/pages/blog/[...slug].astro
export async function getStaticPaths() {
const r = await fetch("https://revnu.com/api/content/v1/posts", {
headers: { Authorization: `Bearer ${import.meta.env.REVNU_TOKEN}` },
});
if (!r.ok) throw new Error(`revnu: ${r.status}`);
const { posts } = await r.json();
return posts.map((p) => ({ params: { slug: p.canonicalPath.slice(1) } }));
}
const parts = String(Astro.params.slug).split("/");
const r = await fetch(`https://revnu.com/api/content/v1/posts/${parts[parts.length - 1]}`, {
headers: { Authorization: `Bearer ${import.meta.env.REVNU_TOKEN}` },
});
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>
<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>Updates on a build-time site
Astro and other static builders render once at deploy. Give Revnu a deploy-hook URL and it POSTs { event, blogSlug, postSlug } on every publish, update and delete. Treat the ping only as "rebuild now"; it is unsigned by design and carries nothing worth trusting.
Agents: this page is also markdown at /docs/host-your-blog.md, answers Accept: text/markdown, and is listed in /docs/llms.txt.
Frequently Asked Questions
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.