CiteRelay
FeaturesHow It WorksGuidesPricing
Sign inSign upGet started free
← CiteRelay/Guides

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 .md file in this folder and copy them straight into your app's content/aeo/ (or merge the whole content tree) with nothing else mixed in.

  • ZIP root — README-*.txt, 00-START-HERE-how-to-render-and-publish.md, sitemap-template.ts, and robots-template.ts. Keep these out of content/aeo/; copy the templates into app/ 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
PathWhat it is
content/aeo/*.md

Generated AEO pages only. Each file is one URL: YAML frontmatter first, then the article body. Names follow the slug in frontmatter when present (otherwise a safe version of the target keyword). If two exports would collide, CiteRelay appends -2, -3, and so on.

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 app/.

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 MetadataRoute.Sitemap that lists /, /guides, and every slug under content/aeo with lastModified from each file. Rename to sitemap.ts and place in app/. Set NEXT_PUBLIC_SITE_URL in .env.local.

robots-template.ts (ZIP root)

Next.js MetadataRoute.Robots with sensible allow / disallow rules and explicit allowances for major AI crawlers. Rename to robots.ts and place in app/.

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):

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):

app/guides/[slug]/page.tsx
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.


1

Install Dependencies

From your project root:

bash
npm install gray-matter react-markdown remark-gfm npm install -D @tailwindcss/typography
PackagePurpose
gray-matter

Parses YAML frontmatter reliably (handles special characters, colons in titles, multiline values)

react-markdownRenders the Markdown body to HTML
remark-gfm

Adds GitHub-Flavored Markdown support (tables, task lists, strikethrough)

@tailwindcss/typographyThe prose utility class for clean article typography

Enable the Typography plugin in your global CSS:

app/globals.css
@import "tailwindcss"; @plugin "@tailwindcss/typography";
tailwind.config.js
module.exports = { plugins: [require("@tailwindcss/typography")], };
2

Place Your Markdown Files on Disk

  1. Create the folder content/aeo/ at your project root (if it does not exist).

  2. Unzip your CiteRelay export. Copy only the files from content/aeo/ in the archive into your project's content/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.

3

Dynamic Page Route

Create app/guides/[slug]/page.tsx:

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_SLUG regex — 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 uses gray-matter to split frontmatter from body. gray-matter handles 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 the Promise<> wrapper or the await.

4

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):

app/guides/[slug]/page.tsx
// 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.

5

Index / List Page

Create app/guides/page.tsx to list all your generated pages:

app/guides/page.tsx
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> ); }
6

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:

.env.local
NEXT_PUBLIC_SITE_URL=https://your-domain.com

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

  1. Open the Google Search Console.

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

  3. Go to Indexing → Sitemaps.

  4. In Add a new sitemap, enter sitemap.xml and click Submit.

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

1

GCP Setup

  1. Open the Google Cloud Console.

  2. Create a project (or pick an existing one). Search for Indexing API and click Enable.

  3. Go to IAM & Admin → Service Accounts and click Create service account.

  4. 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).

  5. Open the new service account → Keys → Add key → Create new key → JSON.

  6. Download the file, rename it to service-account.json, and place it at your project root (same level as package.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.

2

Authorize the Service Account in Search Console

  1. Open the Google Search Console.

  2. Select the property that matches your live site (for example yourdomain.com).

  3. Go to Settings → Users and permissions.

  4. Click Add user.

  5. Paste the service account email from service-account.json ( client_email).

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

3

Security and Hosting Configuration

  1. Never commit the key. Add service-account.json to .gitignore immediately.

  2. Production: In Vercel (or your host), add an environment variable GOOGLE_JSON_KEY whose 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.

4

Indexer Script and Dependencies

Install the official Google client:

bash
npm install googleapis

Create scripts/index-pages.ts:

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):

bash
npx tsx scripts/index-pages.ts

Because 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 — .gitignore plus GOOGLE_JSON_KEY in 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:

next.config.ts
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:

Prompt to paste into your AI coding assistant
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.

Related Reads

Best Ways to Rank Against Big Competitors with AIReducing Bounce Rates on Programmatic PagesThe Role of Vibe Score in Page Rank Algorithms
On this page
  • What's in Your ZIP
    • Typical layout
    • Inside each generated .md file
  • How to dynamically link your content
    • Install Dependencies
    • Place Your Markdown Files on Disk
    • Dynamic Page Route
    • Inject JSON-LD for Answer Engines (Crucial for AEO)
    • Index / List Page
    • Sitemap & Robots
  • Submit Your Sitemap to Google Search Console
  • Fast-Tracking SEO Indexing
    • GCP Setup
    • Authorize the Service Account in Search Console
    • Security and Hosting Configuration
    • Indexer Script and Dependencies
  • Production Deployment Note
  • Appendix — AI Coding Assistant Shortcut
CiteRelay

Get your SaaS recommended by AI search engines through optimized AEO content.

Product

  • Features
  • How It Works
  • Pricing

Company

  • Support

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy

© 2026 CiteRelay. All rights reserved.