Deploying Your CiteRelay Pages to Next.js
This guide walks you through serving your downloaded Markdown files as live, SEO-ready pages in a Next.js 16 App Router project. By the end you will have a working dynamic route, an index page, a sitemap that registers every generated page with search engines, and an optional Google Indexing API workflow to request faster crawling after deploy.
Requirements
Node.js 20.9.0 or higher · Next.js 16 · Tailwind CSS (v3 or v4)
Format note: Generated AEO pages from CiteRelay are standard .md files. Your docs layer can safely mix .md and .mdx files in the same content/ tree.
What's in Your ZIP
Before touching any code, understand what you downloaded. Generated pages live under content/aeo/; everything else sits at the ZIP root next to that folder (README, this guide, and Next.js templates).
content/aeo/— only generated AEO Markdown. You can select every.mdfile in this folder and copy them straight into your app'scontent/aeo/(or merge the wholecontenttree) with nothing else mixed in.ZIP root —
README-*.txt,00-START-HERE-how-to-render-and-publish.md,sitemap-template.ts, androbots-template.ts. Keep these out ofcontent/aeo/; copy the templates intoapp/when you wire up SEO routes.
Typical layout
- README-CiteRelay.txt← export summary & quick pointers
- 00-START-HERE-how-to-render-and-publish.md← this guide (open first)
- sitemap-template.ts← copy → app/sitemap.ts
- robots-template.ts← copy → app/robots.ts
| Path | What it is |
|---|---|
content/aeo/*.md | Generated AEO pages only. Each file is one URL: YAML frontmatter
first, then the article body. Names follow the |
README-CiteRelay.txt (ZIP root) | Plain-text manifest: campaign title, export time, page count, folder
layout, and reminders to open the start-here guide and copy the
templates into |
00-START-HERE-how-to-render-and-publish.md (ZIP root) | The full Next.js App Router walkthrough you are reading now (install deps, dynamic route, index page, JSON-LD, sitemap, deploy notes, and other CMS hints). |
sitemap-template.ts (ZIP root) | Next.js |
robots-template.ts (ZIP root) | Next.js |
Inside each generated .md file
Every page file starts with a YAML frontmatter block — the lines between the --- markers — followed by the page body.
Example (content/aeo/citerelay-vs-letterdrop-comparison.md):
---
title: "CiteRelay vs. Letterdrop: Programmatic SEO for the AI Era"
slug: "citerelay-vs-letterdrop-comparison"
metaDescription: "Compare CiteRelay and Letterdrop for programmatic SEO..."
targetKeywords: ["CiteRelay vs Letterdrop", "programmatic SEO tool comparison"]
vibeScore: 98
jsonLd: |
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Article", "headline": "CiteRelay vs. Letterdrop...", "description": "Compare CiteRelay and Letterdrop..." },
{ "@type": "FAQPage", "mainEntity": [ /* Question nodes mirroring the page's FAQ */ ] }
]
}
---
# CiteRelay vs. Letterdrop: Which Programmatic SEO Tool is Right for Your SaaS?
As software-as-a-service (SaaS) founders scale their organic presence...Do not delete frontmatter
The frontmatter fields are what your app reads for <title> tags, meta descriptions, and canonical URLs. The filename (without .md) is used as the URL slug, and it will always match the slug: field inside. The optional jsonLd field holds a pre-built schema.org payload (rendered in Step 4) — keep it intact so AI answer engines can classify and cite the page.
How to dynamically link your content
CiteRelay generates Markdown only — do not ask an LLM to hardcode internal links into those files. Guessing URL slugs in prose is brittle: delete one page and sibling articles can silently ship broken /guides/... links.
Recommended approach: keep each .md file focused on the article body. Let your framework list sibling files on disk at request time (or at static build time via the same filesystem APIs) and render a Related Reads block in your page component. Links always point at files that actually exist, so the cluster is self-healing when you add, rename, or remove Markdown.
Below, getPost and CONTENT_DIR are the same helpers as in Step 3. This picks three other slugs (excluding the current page) deterministically (no Math.random() in Server Components), then uses each file's frontmatter title (falling back to the slug):
function listAeoSlugs(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".md"))
.map((f) => f.replace(/\.md$/, ""))
.filter((s) => SAFE_SLUG.test(s));
}
function hashStringToUint(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = Math.imul(31, h) + str.charCodeAt(i);
h |= 0;
}
return h >>> 0;
}
function pickRelatedSlugs(
currentSlug: string,
candidates: string[],
count: number,
): string[] {
const pool = candidates.filter((s) => s !== currentSlug);
if (pool.length === 0) return [];
const sorted = [...pool].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
if (sorted.length <= count) return sorted;
const start = hashStringToUint(currentSlug) % sorted.length;
const picked: string[] = [];
for (let i = 0; i < count; i++) {
picked.push(sorted[(start + i) % sorted.length]!);
}
return picked;
}
// Inside GuidePage, after you resolve `slug` and the current `post`:
const relatedGuides = pickRelatedSlugs(slug, listAeoSlugs(), 3).map((s) => {
const other = getPost(s);
return { slug: s, title: (other?.data.title as string | undefined) ?? s };
});
// Below your <ReactMarkdown> output (add `import Link from "next/link"` at the top):
{
relatedGuides.length > 0 ? (
<div className="mt-12 border-t border-border pt-8">
<h2 className="text-lg font-heading font-semibold mb-4">Related Reads</h2>
<div className="grid gap-3">
{relatedGuides.map((g) => (
<Link
key={g.slug}
href={`/guides/${g.slug}`}
className="text-primary hover:underline"
>
{g.title}
</Link>
))}
</div>
</div>
) : null;
}We generate the Markdown; you render internal navigation with your framework's filesystem API so SEO cross-links stay accurate without editing dozens of files by hand.
Install Dependencies
From your project root:
npm install gray-matter react-markdown remark-gfm
npm install -D @tailwindcss/typography| Package | Purpose |
|---|---|
gray-matter | Parses YAML frontmatter reliably (handles special characters, colons in titles, multiline values) |
react-markdown | Renders the Markdown body to HTML |
remark-gfm | Adds GitHub-Flavored Markdown support (tables, task lists, strikethrough) |
@tailwindcss/typography | The prose utility class for clean article typography |
Enable the Typography plugin in your global CSS:
@import "tailwindcss";
@plugin "@tailwindcss/typography";module.exports = {
plugins: [require("@tailwindcss/typography")],
};Place Your Markdown Files on Disk
Create the folder
content/aeo/at your project root (if it does not exist).Unzip your CiteRelay export. Copy only the files from
content/aeo/in the archive into your project'scontent/aeo/— that folder contains nothing but generated pages, so you can paste the whole set at once. Leave the ZIP root files (README, start-here guide,sitemap-template.ts,robots-template.ts) out of this directory.
Your project structure should now look like this:
- page.tsx← you'll create this (index)
- sitemap.ts
- robots.ts
You can rename content/aeo/ to anything you like — just keep it consistent
in the code below.
Dynamic Page Route
Create app/guides/[slug]/page.tsx:
import fs from "fs";
import path from "path";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import matter from "gray-matter";
const CONTENT_DIR = path.join(process.cwd(), "content", "aeo");
const CONTENT_EXTENSIONS = [".md", ".mdx"] as const;
// Only allow URL-safe slugs — prevents path traversal attacks
const SAFE_SLUG = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
function getPost(slug: string) {
if (!SAFE_SLUG.test(slug)) return null;
const contentDirResolved = path.resolve(CONTENT_DIR);
for (const ext of CONTENT_EXTENSIONS) {
const filePath = path.resolve(path.join(CONTENT_DIR, `${slug}${ext}`));
// Ensure resolved path stays within the content directory
if (!filePath.startsWith(contentDirResolved + path.sep)) continue;
try {
const raw = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(raw);
return { data, content };
} catch {
// Try next extension
}
}
return null;
}
// Tells Next.js which slugs to pre-render at build time (SSG)
export async function generateStaticParams() {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => /\.(md|mdx)$/i.test(f))
.map((f) => ({ slug: f.replace(/\.(md|mdx)$/i, "") }))
.filter(({ slug }) => SAFE_SLUG.test(slug));
}
// Populates <title> and <meta name="description"> for each page
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return { title: "Not found" };
return {
title: post.data.title ?? slug,
description: post.data.metaDescription ?? undefined,
keywords: post.data.targetKeywords ?? undefined,
openGraph: {
title: post.data.title ?? slug,
description: post.data.metaDescription ?? undefined,
},
};
}
export default async function GuidePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
return (
<main className="mx-auto max-w-3xl px-4 py-12">
<article className="rounded-xl border border-border bg-background p-8">
<h1 className="mb-8 text-3xl font-semibold">
{post.data.title ?? slug}
</h1>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{post.content}
</ReactMarkdown>
</div>
</article>
</main>
);
}What each part does
SAFE_SLUGregex — restricts slugs to letters, numbers, and hyphens. This prevents a malicious URL from escaping your content directory (e.g.../../etc/passwd).getPost— reads a single file and usesgray-matterto split frontmatter from body.gray-matterhandles edge cases like colons in titles and quoted strings that a hand-rolled parser would miss.generateStaticParams— tells Next.js every valid slug at build time so all pages are pre-rendered as static HTML. Fast, SEO-friendly, zero server cost per request.generateMetadata— injects<title>, meta description, keywords, and Open Graph tags from your frontmatter fields automatically.params: Promise<{ slug: string }>— the async params pattern required in Next.js 15 and 16. Do not remove thePromise<>wrapper or theawait.
Inject JSON-LD for Answer Engines (Crucial for AEO)
AI bots look for structured JSON-LD data to safely parse facts. CiteRelay already builds this for you — every page ships a jsonLd frontmatter field containing a complete schema.org payload (an Article node plus a FAQPage node that mirrors the page's FAQ section, and a HowTo node when the page is a step-by-step guide). You don't need to hand-assemble schema from individual fields — just render the field as-is.
The jsonLd value is a YAML block scalar holding a pre-serialized JSON string, so gray-matter hands it back as a string you JSON.parse. Render it as a plain inline <script> from your Server Component (the approach the Next.js docs recommend for JSON-LD — it lands in the initial HTML so crawlers see it immediately):
// Parses the `jsonLd` frontmatter field. Accepts a JSON string (the normal
// case) or an already-parsed object; returns null on anything malformed so a
// bad payload can never break the page render.
function getJsonLd(value: unknown): object | unknown[] | null {
let parsed: unknown = value;
if (typeof value === "string") {
if (!value.trim()) return null;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
}
return parsed && typeof parsed === "object" ? (parsed as object) : null;
}
// Escapes characters that could let the payload break out of the <script> tag.
function safeJsonLd(data: object | unknown[]): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026");
}
// Inside GuidePage, after you resolve `post`:
const jsonLd = getJsonLd(post.data.jsonLd);
// Inside your return() statement (e.g. at the top of <main>):
{
jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
);
}If a page predates this field (no jsonLd in its frontmatter), getJsonLd
returns null and the script is simply skipped — no schema is better than
broken schema. To add schema to an older page, paste a jsonLd: block into its
frontmatter or regenerate the page in CiteRelay.
Index / List Page
Create app/guides/page.tsx to list all your generated pages:
import fs from "fs";
import path from "path";
import Link from "next/link";
import matter from "gray-matter";
import type { Metadata } from "next";
const CONTENT_DIR = path.join(process.cwd(), "content", "aeo");
const CONTENT_EXTENSIONS = [".md", ".mdx"] as const;
export const metadata: Metadata = {
title: "Guides",
description: "Browse all programmatic SEO guides.",
};
function getAllGuides() {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) =>
CONTENT_EXTENSIONS.some((ext) => f.toLowerCase().endsWith(ext)),
)
.map((filename) => {
const slug = filename.replace(/\.(md|mdx)$/i, "");
const raw = fs.readFileSync(path.join(CONTENT_DIR, filename), "utf8");
const { data } = matter(raw);
return {
slug,
title: (data.title as string) ?? slug,
description: (data.metaDescription as string) ?? "",
};
});
}
export default function GuidesIndexPage() {
const guides = getAllGuides();
return (
<main className="mx-auto max-w-3xl px-4 py-12">
<h1 className="mb-8 text-3xl font-semibold">Guides</h1>
<ul className="space-y-4">
{guides.map((guide) => (
<li key={guide.slug}>
<Link
href={`/guides/${guide.slug}`}
className="block rounded-lg border border-neutral-200 p-5 transition hover:border-neutral-400 dark:border-neutral-800 dark:hover:border-neutral-600"
>
<p className="font-medium">{guide.title}</p>
{guide.description && (
<p className="mt-1 text-sm text-neutral-500">
{guide.description}
</p>
)}
</Link>
</li>
))}
</ul>
</main>
);
}Sitemap & Robots
Starter sitemap.ts and robots.ts implementations ship next to content/aeo/ in the ZIP as sitemap-template.ts and robots-template.ts. Copy them to app/sitemap.ts and app/robots.ts (or merge into your existing files). Set your domain in one place:
NEXT_PUBLIC_SITE_URL=https://your-domain.comBoth files read process.env.NEXT_PUBLIC_SITE_URL and fall back to
https://your-domain.com if the variable is not set. The sitemap
automatically picks up every .md file in content/aeo/ — no manual updates
needed when you add more pages.
Submit Your Sitemap to Google Search Console
Once your production deploy is live and https://your-domain.com/sitemap.xml returns a valid sitemap, submit it in Google Search Console so Google can discover the new page set sooner.
Open the Google Search Console.
Select the property for your live site. If you have not added the site yet, add and verify the domain or URL-prefix property first.
Go to Indexing → Sitemaps.
In Add a new sitemap, enter
sitemap.xmland click Submit.Check that the status changes to Success. If Search Console reports an error, open the sitemap URL directly in your browser and confirm it is public, returns
200, and uses your production domain.
Sitemap submission does not guarantee instant ranking, but it gives Google a clean
URL inventory immediately after launch. Keep your app/sitemap.ts dynamic so new
CiteRelay pages appear there automatically on each deploy.
Fast-Tracking SEO Indexing
Waiting for Google to crawl new URLs on its own can take days or weeks. To request indexing on the order of hours, use the Google Indexing API after your pages are live.
GCP Setup
Open the Google Cloud Console.
Create a project (or pick an existing one). Search for Indexing API and click Enable.
Go to IAM & Admin → Service Accounts and click Create service account.
Give it a name (for example
seo-indexer). Attach a project role that is enough for this workflow in your org (many teams use Editor on a dedicated project; avoid Owner unless your organization requires it).Open the new service account → Keys → Add key → Create new key → JSON.
Download the file, rename it to
service-account.json, and place it at your project root (same level aspackage.json).
Treat this like a password
The service-account.json file grants API access to your Google Cloud
project. Never share it or check it into version control.
Authorize the Service Account in Search Console
Open the Google Search Console.
Select the property that matches your live site (for example
yourdomain.com).Go to Settings → Users and permissions.
Click Add user.
Paste the service account email from
service-account.json(client_email).Set permission to Owner.
Indexing API workflows commonly fail silently if the account only has "Full" or lower permissions. Use Owner for this invited user.
Security and Hosting Configuration
Never commit the key. Add
service-account.jsonto.gitignoreimmediately.Production: In Vercel (or your host), add an environment variable
GOOGLE_JSON_KEYwhose value is the entire JSON pasted as a single string (the same object you would have in the file). Your script should read that at runtime instead of checking in the file.
Indexer Script and Dependencies
Install the official Google client:
npm install googleapisCreate scripts/index-pages.ts:
import fs from "fs";
import path from "path";
import { google } from "googleapis";
/**
* Same convention as `sitemap-template.ts` in your CiteRelay ZIP:
* NEXT_PUBLIC_SITE_URL=https://your-domain.com
*/
const BASE_URL =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ??
"https://your-domain.com";
const CONTENT_DIR = path.join(process.cwd(), "content", "aeo");
/* Match Step 3 — only URL-safe slug segments (prevents odd filenames). */
const SAFE_SLUG = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
function listAeoSlugsFromDisk(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".md"))
.map((f) => f.replace(/\.md$/, ""))
.filter((slug) => SAFE_SLUG.test(slug));
}
function absolutePublicUrl(pathname: string): string {
const p = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${BASE_URL}${p}`;
}
const key = process.env.GOOGLE_JSON_KEY
? JSON.parse(process.env.GOOGLE_JSON_KEY)
: require("../service-account.json");
const auth = new google.auth.JWT({
email: key.client_email,
key: key.private_key,
scopes: ["https://www.googleapis.com/auth/indexing"],
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function notifyGoogle(url: string) {
await auth.authorize();
const indexer = google.indexing("v3");
try {
await indexer.urlNotifications.publish({
auth,
requestBody: {
url,
type: "URL_UPDATED",
},
});
console.log(`✅ Successfully notified Google for: ${url}`);
} catch (error: any) {
console.error(`❌ Failed to notify: ${url} - ${error.message}`);
}
}
/**
* Batch URL notification — authorizes once, then one publish per URL.
*/
export async function notifyGoogleBatch(urls: string[]) {
console.log(`Starting batch indexing for ${urls.length} pages...`);
await auth.authorize();
const indexer = google.indexing("v3");
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
try {
await indexer.urlNotifications.publish({
auth,
requestBody: {
url,
type: "URL_UPDATED",
},
});
console.log(`[${i + 1}/${urls.length}] ✅ Indexed: ${url}`);
} catch (error: any) {
console.error(
`[${i + 1}/${urls.length}] ❌ Failed: ${url} - ${error.message}`,
);
}
if (i < urls.length - 1) {
await sleep(500);
}
}
console.log("Batch indexing complete! 🚀");
}
/**
* Same logical URL set as `sitemap-template.ts`: home, guides index, each
* `/guides/[slug]` from `content/aeo/`. Extend the array if your sitemap
* includes more fixed routes.
*/
function publicSiteUrlsForIndexing(): string[] {
const slugs = listAeoSlugsFromDisk();
const pageUrls = slugs.map((slug) => absolutePublicUrl(`/guides/${slug}`));
return [absolutePublicUrl("/"), absolutePublicUrl("/guides"), ...pageUrls];
}
// From project root, with NEXT_PUBLIC_SITE_URL pointing at production:
// npx tsx scripts/index-pages.ts
async function run() {
await notifyGoogleBatch(publicSiteUrlsForIndexing());
}
run();Run it after a production deploy (so URLs return 200):
npx tsx scripts/index-pages.tsBecause this version uses only fs, path, and googleapis, you do not need tsconfig path aliases; ts-node is fine if you already use it elsewhere.
publish is always called with auth so credentials attach to each request. For hundreds of URLs, the built-in delay helps; adjust sleep if Google returns rate-related errors.
Why this section is written this way
Safety first —
.gitignoreplusGOOGLE_JSON_KEYin the host dashboard makes it obvious how to avoid leaking a key; if someone commits JSON anyway, that is on the process, not on missing docs.No ambiguity on Search Console — calling out Owner for the invited service account addresses the most common silent failure.
One script, two environments — the same code path reads the env var in production and the local file during development.
Production Deployment Note
If you deploy to Vercel, the content/ folder is included automatically because generateStaticParams references it at build time — Next.js traces the dependency.
If you self-host with Docker or a custom server, add this to your next.config.ts to ensure the content directory is bundled into the output:
const nextConfig = {
experimental: {
outputFileTracingIncludes: {
"/guides/[slug]": ["./content/aeo/**"],
},
},
};
export default nextConfig;Without this, you may see a ENOENT: no such file or directory error in
production that works fine in local development.
Appendix — AI Coding Assistant Shortcut
If you use Cursor, Claude Code, or another AI assistant inside your codebase, paste this prompt to have it implement everything automatically:
I am adding programmatic SEO pages to this project.
I will place a batch of Markdown files in `content/aeo/`. Each file includes YAML frontmatter
with fields: `title`, `slug`, `metaDescription`, `targetKeywords`, `vibeScore`, and an optional
`jsonLd` (a YAML block scalar holding a pre-serialized schema.org JSON string).
Please implement this for the current framework:
1. Dynamic routing so each Markdown file is served at its URL slug.
2. Safe file reading (prevent path traversal — validate slug characters and resolved path).
3. Parse frontmatter with `gray-matter` and use it to populate `<title>`, meta description,
Open Graph, and keywords.
4. Render the pre-built `jsonLd` frontmatter field as an inline `<script type="application/ld+json">`
from the Server Component: JSON.parse the string (it may already be an object), escape `<`, `>`,
and `&`, and skip rendering when the field is absent or malformed. Do NOT hand-assemble schema
from other fields — the `jsonLd` field already contains Article + FAQPage (+ HowTo) nodes.
5. Render the Markdown body with `react-markdown` + `remark-gfm` inside a `prose` container
from `@tailwindcss/typography`.
6. An index page listing all guides with title and description.
7. A `sitemap.ts` that includes all generated page URLs using the file's `mtime` as `lastModified`.
8. Install commands for any new dependencies.
9. (Optional) After deploy, a self-contained `scripts/index-pages.ts`: `googleapis` + Google
Indexing API (`URL_UPDATED`), credentials from `GOOGLE_JSON_KEY` or a gitignored JSON key
beside the repo root, batch requests with a short delay, public URLs built from
`NEXT_PUBLIC_SITE_URL` (same as `sitemap-template.ts`) plus slugs read from `content/aeo/`,
and the service account invited as **Owner** in Google Search Console.
Follow existing code style, reuse existing components, and keep everything production-safe
with null checks and graceful fallbacks.