Skip to content

SEO metadata & sitemap

Every Article carries the fields Next needs for generateMetadata and app/sitemap.ts. There’s no SEO helper to learn — you map the fields you already have. For the full schema-markup story (JSON-LD from author.kind), see SEO & JSON-LD.

seoTitle is always a string (it falls back to title server-side). seoDescription and canonicalUrl are genuinely optional, so coalesce the nulls — and only set alternates.canonical when a canonical URL is present:

app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { gw } from "@/lib/ghostwritr";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const article = await gw.getArticle((await params).slug);
if (!article) return {};
return {
title: article.seoTitle, // always a string
description: article.seoDescription ?? undefined, // null → omit
alternates: article.canonicalUrl
? { canonical: article.canonicalUrl } // only when set
: undefined,
};
}

You can enrich further from the same object — article.image for Open Graph (url, alt, width, height), article.inLanguage for <html lang>, article.tags for keywords:

return {
title: article.seoTitle,
description: article.seoDescription ?? undefined,
openGraph: article.image
? { images: [{ url: article.image.url, alt: article.image.alt }] }
: undefined,
};

There’s no sitemap helper by design — build it from getArticles(). Map each article to a { url, lastModified } entry, using canonicalUrl when present and falling back to your own route, and updatedAt for lastModified:

app/sitemap.ts
import type { MetadataRoute } from "next";
import { gw } from "@/lib/ghostwritr";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await gw.getArticles();
return articles.map((a) => ({
url: a.canonicalUrl ?? `https://yoursite.com/blog/${a.slug}`,
lastModified: a.updatedAt, // ISO-8601 string
}));
}

getArticles() returns newest-first, and updatedAt is an ISO-8601 string that Next accepts directly for lastModified.

app/sitemap.ts reads the feed, so it caches with your rendering mode like any other route. If you use instant revalidation, add the sitemap path so it refreshes on publish alongside your articles:

app/api/revalidate/route.ts
import { createRevalidateHandler } from "@ghostwritr/next/revalidate";
export const POST = createRevalidateHandler({
secret: process.env.GHOSTWRITR_FEED_SECRET!,
tags: ["ghostwritr"],
paths: ["/blog", "/sitemap.xml"],
});