# 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.

Canonical: https://revnu.com/docs/host-your-blog
Last updated: 2026-10-22

Two complete examples. Both keep the token in a server-side environment variable and render on the server, which is [the one hard rule](https://revnu.com/docs/content-api.md). 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](https://revnu.com/docs/content-api.md). If it's a single-page app built in Lovable, Vite or similar, or a site builder like Wix, use [the Revnu-hosted blog](https://revnu.com/docs/hosted-blog.md) instead. Posts fetched in the browser aren't reliably seen by search or AI engines.

## Before you click I've Connected It

### 1. Deploy your site

Ship the code below to your live site, with `REVNU_CONTENT_API_KEY` set to your current key.

### 2. 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.

### 3. 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

```ts
// 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();
}
```

```tsx
// 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>
  );
}
```

```ts
// 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**:

```ts
// 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

```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>
```

```ts
// 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 rendering `body.markdown` with a renderer that doesn't support GFM tables. Turn on GFM (for example `remark-gfm`, or `gfm: true` in `marked`), or render `body.html` instead.
- **Tables render but have no borders or spacing.** `body.html` contains a plain `<table>`. Your site's CSS needs table styles for the blog container.
- **Links between posts 404.** Links in `body.html` are 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.
