Next.js (App Router)
A blog under /blog with static pages, full SEO metadata, a sitemap, and a refresh on publish.
Tested on Next.js 16 (App Router, with or without src/). On Next.js 15, one line differs; it's marked below.
What you get: /blog and /blog/[slug] prerendered at build time, an article published later rendered on its first visit, every read cached and tagged, and the webhook refreshing that tag.
1. Set where articles live
Dashboard → your site → Settings → Site and URLs → Articles live under: /blog. SEO for SaaS builds canonical URLs and the links between articles on it. Until you set it, articles live at the site root. Articles written before you set it keep their old paths.
2. Environment
SEOFORSAAS_PROJECT_ID=YOUR_PROJECT_IDSEOFORSAAS_CONTENT_KEY= # Delivery → Content API keys → Create keySEOFORSAAS_WEBHOOK_SECRET= # Delivery → Webhook → Signing secret → RevealSITE_URL=http://localhost:3000 # your dev URL; the production origin in your hostSITE_URL is your site's public origin: canonical URLs and the sitemap resolve against it, and a production build without it stops with an error instead of publishing links to localhost. Set the same variables in your host's settings for production.
3. Install
npx shadcn@latest add https://seoforsaas.dev/r/client.json https://seoforsaas.dev/r/webhook.json https://seoforsaas.dev/r/article.jsonnpm install server-onlyThis adds lib/seoforsaas/ (types, API client, webhook handler) and components/seoforsaas/ (React components). They are source files in your repository: read them, and customize through components and the client options instead of edits, so reinstalling a newer version stays a clean overwrite. Without a src/ directory, drop src/ from the paths. server-only makes the build fail if the data module below is ever imported into browser code, so the key can't leak there.
4. Data
/** * The site's public origin. Canonical URLs, Open Graph URLs and the sitemap * resolve against it, so a production build without it fails here, by name, * instead of publishing pages that point at localhost. */function siteUrl(): URL { const value = process.env.SITE_URL; if (value) return new URL(value); if (process.env.NODE_ENV === "production") { throw new Error("SITE_URL is not set: canonical URLs and the sitemap need the site's origin"); } return new URL("http://localhost:3000");}export const SITE_URL = siteUrl();import "server-only";import { cache } from "react";import { createClient } from "@/lib/seoforsaas/client";/** Every read carries this tag, so one webhook call refreshes the whole blog. */export const BLOG_TAG = "seoforsaas";const seoforsaas = createClient({ projectId: process.env.SEOFORSAAS_PROJECT_ID, contentKey: process.env.SEOFORSAAS_CONTENT_KEY, // Requests that carry an Authorization header are cached only when asked. requestInit: { cache: "force-cache", next: { tags: [BLOG_TAG] } },});// cache(): generateMetadata and the page share one read per request.export const listArticles = cache(() => seoforsaas.listAllArticles());export const getArticle = cache((slug: string) => seoforsaas.getArticle(slug));export const getSitemap = cache(() => seoforsaas.getSitemap());force-cache matters: Next.js caches a fetch that carries an Authorization header only when asked to, and without it the tag below would have nothing to revalidate.
5. Pages
// The article styles, once for every blog page.import "@/components/seoforsaas/article.css";export default function BlogLayout({ children }: { children: React.ReactNode }) { return children;}import type { Metadata } from "next";import Link from "next/link";import { listArticles } from "@/lib/blog";export const metadata: Metadata = { title: "Blog" };export default async function BlogIndex() { const articles = await listArticles(); return ( <main> <h1>Blog</h1> {articles.length === 0 && <p>No articles yet.</p>} <ul> {articles.map((article) => ( <li key={article.id}> <h2> <Link href={`/blog/${article.slug}`}>{article.title}</Link> </h2> <p>{article.seo.metaDescription}</p> <time dateTime={article.createdAt}>{new Date(article.createdAt).toLocaleDateString("en")}</time> </li> ))} </ul> </main> );}import type { Metadata } from "next";import Link from "next/link";import { notFound } from "next/navigation";import { Article } from "@/components/seoforsaas/article";import { JsonLd } from "@/components/seoforsaas/json-ld";import { safeHref } from "@/components/seoforsaas/rich-text";import { getArticle, listArticles } from "@/lib/blog";type Props = { params: Promise<{ slug: string }> };// Prerender what exists at build time; an article published later renders on// its first visit and is cached from then on.export async function generateStaticParams() { const articles = await listArticles(); return articles.map(({ slug }) => ({ slug }));}export async function generateMetadata({ params }: Props): Promise<Metadata> { const article = await getArticle((await params).slug); if (!article) return {}; const { seo } = article; return { title: seo.metaTitle, description: seo.metaDescription, keywords: seo.keywords, alternates: { canonical: seo.canonicalPath }, openGraph: { type: "article", title: seo.openGraph.title, description: seo.openGraph.description, images: seo.openGraph.image ? [seo.openGraph.image] : undefined, publishedTime: article.createdAt, modifiedTime: article.updatedAt, authors: seo.author ? [seo.author.name] : undefined, }, };}export default async function ArticlePage({ params }: Props) { const article = await getArticle((await params).slug); if (!article) notFound(); return ( <article> <h1>{article.title}</h1> {article.thumbnailUrl && ( // eslint-disable-next-line @next/next/no-img-element -- hosted and sized by the API's CDN <img src={article.thumbnailUrl} alt="" width={1536} height={1024} style={{ maxWidth: "100%", height: "auto" }} /> )} <Article blocks={article.blocks} /> {article.related.length > 0 && ( <nav aria-label="Related articles"> <h2>Related</h2> <ul> {article.related.map((link) => { const href = safeHref(link.href); return href ? ( <li key={href}> <Link href={href}>{link.title}</Link> </li> ) : null; })} </ul> </nav> )} <JsonLd data={article.seo.jsonLd} /> </article> );}In the root layout, set metadataBase so the relative canonical and Open Graph URLs resolve:
import { SITE_URL } from "@/lib/site";export const metadata: Metadata = { metadataBase: SITE_URL, // …your existing metadata};To serve the pages from another path, change Articles live under to match.
6. Sitemap
import type { MetadataRoute } from "next";import { getSitemap } from "@/lib/blog";import { SITE_URL } from "@/lib/site";export default async function sitemap(): Promise<MetadataRoute.Sitemap> { // Articles arrive ready: the canonical URL and the date a reader last saw // the page change. Add your own pages next to them. return [{ url: SITE_URL.toString() }, ...(await getSitemap())];}getSitemap() returns every published article as { url, lastModified }, the shape Next.js takes as is, so the sitemap needs no mapping. lastModified moves only when a reader would see a change, which is what makes Google trust it (Sitemap). Webhook refreshes purge it with the rest of the blog, because it reads through the same tag.
The URLs are built on the website URL you added the site with (shown in Settings → Site and URLs), so SITE_URL must be that same origin. If you already have a sitemap, spread the entries into it.
7. Refresh on publish
import { revalidateTag } from "next/cache";import { BLOG_TAG } from "@/lib/blog";import { seoforsaasWebhook } from "@/lib/seoforsaas/webhook";// Verifies the signature (401 if it fails). On Next.js 15, call revalidateTag(BLOG_TAG).export const POST = seoforsaasWebhook(() => revalidateTag(BLOG_TAG, { expire: 0 }));Deploy, then paste https://your-site.com/api/seoforsaas into Delivery → Webhook and select Send test event. The delivery shows as delivered. From then on, when an article is published, changed, or unpublished, the change appears on the next visit to the page.
Check
| Check | Expect |
|---|---|
npx next build | /blog/[slug] lists your articles as prerendered (●) |
| Open an article, view source | one <title>, a description, a canonical link, og: tags, one <script type="application/ld+json"> tag (the string also appears in Next.js's page data, so count the tag) |
curl -X POST <your site>/api/seoforsaas | 401: unsigned deliveries are refused |
This guide is tested on a fresh create-next-app, built and run against the example article.