This is the full developer documentation for CitedSEO # Your articles, in your own stack. > Show the articles CitedSEO writes on your own site. A static feed with no key, signed webhooks when an article goes live or changes, and a guide for your framework. # Read the feed > The CitedSEO feed is static JSON on feeds.citedseo.ai, a small manifest that points at pages of articles and one file per article, with no key. The feed is how a site that shows its articles itself gets them. It is static JSON, served from `feeds.citedseo.ai` through Cloudflare’s network, so reading it is fast and you can read it as often as you like. It is not an API you query. It is a snapshot of exactly the articles that are live on your site, written again each time one is published, changed or taken down, and when the site’s article settings or an author change. ## The files [Section titled “The files”](#the-files) Everything for one site lives under its [site ID](/site-id): ```txt feeds.citedseo.ai/{siteId}/ feed/index.json the manifest b/{buildId}/page-1.json 20 articles a page, newest first b/{buildId}/page-2.json b/{buildId}/by-slug/{slug}.json one article ``` There is no key, no header and no query parameter on any of them. Any origin may read them, so they work from the browser too. ## Reading it [Section titled “Reading it”](#reading-it) 1. **Read the manifest.** It is the only file that changes in place, and it names the current build. 2. **Read from that build.** Its pages for the whole list, or one by-slug file for a single article. Always start from the manifest. Never keep a `buildId` in your code: it changes with every publish. ## The manifest [Section titled “The manifest”](#the-manifest) GET`https://feeds.citedseo.ai/{siteId}/feed/index.json` * `buildId`string The current build. Use it in the address of every other file. * `total`number How many articles are live. * `pageCount`number How many pages the build has. `0` when nothing is live. * `pageSize`number How many articles a full page holds: `20`. * `latestModifiedAt`string | null When the newest article in the build last changed, as an ISO 8601 time in UTC. `null` when nothing is live. * `generatedAt`string When this build was written. * `removed`true Present only on the feed of a site that has been removed from CitedSEO: the manifest stays, with `total` 0, and nothing more is published to it. 200 OK ```json { "buildId": "01JA2M7Q4K8ZP3", "total": 42, "pageCount": 3, "pageSize": 20, "latestModifiedAt": "2026-10-11T02:05:00.000Z", "generatedAt": "2026-10-11T02:05:04.000Z" } ``` ## A page of articles [Section titled “A page of articles”](#a-page-of-articles) GET`https://feeds.citedseo.ai/{siteId}/b/{buildId}/page-{n}.json` Pages are numbered from `1` to the manifest’s `pageCount`. Each holds up to 20 articles, newest first. * `articles`Article\[] The page’s [articles](/feed/article), whole: every field, with both bodies. 200 OK ```json { "articles": [ { "id": "0b6f6c1e-3f0a-4a59-9d0e-2f8f4c1f7a31", "slug": "how-to-brew-green-tea", "url": "https://example.com/blog/how-to-brew-green-tea", "title": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temp…", "cover": { "src": "https://example.com/images/green-tea.webp", "alt": "A glass teapot of green tea" }, "author": { "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publishedAt": "2026-10-09T02:05:00.000Z", "modifiedAt": "2026-10-11T02:05:00.000Z", "html": "

Brew green tea at 80°C for two minutes. Hot…", "markdown": "**Brew green tea at 80°C for two minutes. Hotter water makes…", "structuredData": { "@context": "https://schema.org", "@graph": [ "…" ] } } ] } ``` The long fields are cut short above. ## One article [Section titled “One article”](#one-article) GET`https://feeds.citedseo.ai/{siteId}/b/{buildId}/by-slug/{slug}.json` Put the slug in the address as the article’s `slug` has it. That is already URL-encoded (`caf%C3%A9`, not `café`), so don’t encode it again; encode a slug only when it reached you decoded, as most routers give it. The file answers `404` when no live article has that slug, which is how you tell a page that doesn’t exist. * `article`Article The [article](/feed/article), the same object a page holds. 200 OK ```json { "article": { "id": "0b6f6c1e-3f0a-4a59-9d0e-2f8f4c1f7a31", "slug": "how-to-brew-green-tea", "url": "https://example.com/blog/how-to-brew-green-tea", "title": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temp…", "cover": { "src": "https://example.com/images/green-tea.webp", "alt": "A glass teapot of green tea" }, "author": { "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publishedAt": "2026-10-09T02:05:00.000Z", "modifiedAt": "2026-10-11T02:05:00.000Z", "html": "

Brew green tea at 80°C for two minutes. Hot…", "markdown": "**Brew green tea at 80°C for two minutes. Hotter water makes…", "structuredData": { "@context": "https://schema.org", "@graph": [ "…" ] } } } ``` ```js const feed = 'https://feeds.citedseo.ai/YOUR_SITE_ID'; const manifest = await fetch(`${feed}/feed/index.json`).then((res) => res.json()); // slug as the article has it, already URL-encoded. const res = await fetch(`${feed}/b/${manifest.buildId}/by-slug/${slug}.json`); if (!res.ok && res.status !== 404) throw new Error(`The feed answered ${res.status}`); // 404 is the one answer that means there is no such article. const article = res.ok ? (await res.json()).article : null; ``` ## What is in the feed [Section titled “What is in the feed”](#what-is-in-the-feed) * **Only what is live.** An article appears once it has been published, at the version that is live. A newer draft waiting for approval is not in the feed until it ships. * **Every live article, wherever it publishes.** A site connected to WordPress has the same articles in its feed as on WordPress. * **Nothing private.** The feed is what your site already shows the public. When an article is taken down The feed is rebuilt without it: it is gone from the pages and its by-slug file answers `404` in the new build. There is no “deleted” entry to look for. ## Next [Section titled “Next”](#next) [The article](/feed/article)Every field, with a full example. [Caching and limits](/feed/caching)What is cached for how long, and why builds never change. # The article > Every field of an article as the CitedSEO feed serves it and a webhook sends it, with a full example. An article has one shape. A feed page holds a list of them, a by-slug file holds one, and a webhook sends one, so code that draws one draws them all. ## Fields [Section titled “Fields”](#fields) * `id`string The article’s ID. It never changes, so use it as your key when you store articles. * `slug`string The last part of the article’s address, to route by. For `https://example.com/blog/how-to-brew-green-tea` it is `how-to-brew-green-tea`. * `url`string The article’s full address on your site. Use it as the canonical address. * `title`string The title, as plain text. * `description`string The search snippet, for the page’s meta description. * `cover`object | null The cover image as `{ src, alt }`, or `null` when the article has none. * `author`object | null The byline as `{ name, url }`, where `url` is the author’s page or `null`. The whole field is `null` when the article is by the site itself. * `publishedAt`string When the article first went live, as an ISO 8601 time in UTC. * `modifiedAt`string When the live version last changed, as an ISO 8601 time in UTC. * `html`string The body as HTML, without the title or cover. See [The body as HTML](/feed/html). * `markdown`string The same body as Markdown. See [The body as Markdown](/feed/markdown). * `structuredData`object | null The article’s schema.org JSON-LD, or `null` when structured data is switched off in the site’s article settings. See [Structured data](/feed/structured-data). The title and cover are not in the body `html` and `markdown` start at the first line of the article. Draw `title` and `cover` yourself, where your design wants them. ## TypeScript [Section titled “TypeScript”](#typescript) The types come with [the packages](/sdks), so there is nothing to copy: ```ts import type { FeedArticle, FeedManifest } from '@citedseo/feed'; ``` This is the file they are defined in, as it is in the package. @citedseo/feed ```ts /** What every read takes. */ export type FeedOptions = { /** Your site ID. It is the only thing the feed needs. */ siteId: string /** Another origin to read from, for tests. Defaults to https://feeds.citedseo.ai. */ origin?: string /** Your own fetch: a framework's caching one, or a test's. Defaults to the global fetch. */ fetch?: typeof fetch } /** An article's schema.org JSON-LD: a BlogPosting, an FAQPage when it has an FAQ, and a BreadcrumbList. */ export type StructuredData = { "@context": string "@graph": Record[] } /** One live article, as a feed page holds it, a by-slug file holds it, and a webhook sends it. */ export type FeedArticle = { /** The article's ID. It never changes, so use it as your key. */ id: string /** The last part of its address, to route by. */ slug: string /** The article's full address on your site. Use it as the canonical address. */ url: string /** The title, as plain text. */ title: string /** The search snippet, for the page's meta description. */ description: string /** The cover image, or null when the article has none. */ cover: { src: string; alt: string } | null /** The byline, or null when the article is by the site itself. */ author: { name: string; url: string | null } | null /** When the article first went live, as an ISO 8601 time in UTC. */ publishedAt: string /** When the live version last changed, as an ISO 8601 time in UTC. */ modifiedAt: string /** The body as HTML, without the title or cover. Safe to insert: no scripts, no raw HTML. */ html: string /** The same body as Markdown (CommonMark with GitHub's extras). */ markdown: string /** The article's JSON-LD, or null when the site has structured data switched off. */ structuredData: StructuredData | null } /** `feed/index.json`: the one file that changes in place. It names the current build. */ export type FeedManifest = { /** The current build. It is part of the address of every other file. */ buildId: string /** How many articles are live. */ total: number /** How many pages the build has. 0 when nothing is live. */ pageCount: number /** How many articles a full page holds. */ pageSize: number /** When the newest article in the build last changed, or null when nothing is live. */ latestModifiedAt: string | null /** When this build was written. */ generatedAt: string /** True once the site is gone from CitedSEO: its feed stays empty for good. */ removed?: true } /** `b/{buildId}/page-{n}.json`: one page of articles, newest first. */ export type FeedPage = { articles: FeedArticle[] } /** `b/{buildId}/by-slug/{slug}.json`: one article. */ export type FeedArticleFile = { article: FeedArticle } /** The events a webhook sends. */ export const webhookEvents = ["article.published", "article.updated"] as const export type WebhookEvent = (typeof webhookEvents)[number] /** The JSON body of a webhook. */ export type WebhookPost = { event: WebhookEvent /** The same on every try of the same post, so you can tell one you already took. */ delivery: string /** The article as it is live now. */ data: FeedArticle } ``` ## A full example [Section titled “A full example”](#a-full-example) One article ```json { "id": "0b6f6c1e-3f0a-4a59-9d0e-2f8f4c1f7a31", "slug": "how-to-brew-green-tea", "url": "https://example.com/blog/how-to-brew-green-tea", "title": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temperature matters and how to get it right without a thermometer.", "cover": { "src": "https://example.com/images/green-tea.webp", "alt": "A glass teapot of green tea" }, "author": { "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publishedAt": "2026-10-09T02:05:00.000Z", "modifiedAt": "2026-10-11T02:05:00.000Z", "html": "

Brew green tea at 80°C for two minutes. Hotter water makes it bitter.

\n

Why temperature matters

\n

Boiling water pulls out more tannins, which taste bitter. See our tea guide.

\n\n
    \n
  1. Heat the water to 80°C.
  2. \n
  3. Steep for two minutes.
  4. \n
  5. Pour and serve.
  6. \n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
TeaTemperature
Green80°C
Black95°C
\n

Frequently asked questions

\n

Can I use boiling water?

\n

You can, but the tea will taste bitter.

\n

Sources

\n
    \n
  1. Brewing temperatures, Example Tea Board
  2. \n
", "markdown": "**Brew green tea at 80°C for two minutes. Hotter water makes it bitter.**\n\n## Why temperature matters\n\nBoiling water pulls out more tannins, which taste **bitter**. See our [tea guide](/blog/tea-guide).\n\n> [!TIP]\n> No thermometer? Let boiled water stand for three minutes.\n\n1. Heat the water to 80°C.\n2. Steep for two minutes.\n3. Pour and serve.\n\n| Tea | Temperature |\n| - | - |\n| Green | 80°C |\n| Black | 95°C |\n\n## Frequently asked questions\n\n### Can I use boiling water?\n\nYou can, but the tea will taste bitter.\n\n## Sources\n\n1. [Brewing temperatures](https://example.org/brewing), Example Tea Board", "structuredData": { "@context": "https://schema.org", "@graph": [ { "@type": "BlogPosting", "@id": "https://example.com/blog/how-to-brew-green-tea#article", "headline": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temperature matters and how to get it right without a thermometer.", "url": "https://example.com/blog/how-to-brew-green-tea", "mainEntityOfPage": "https://example.com/blog/how-to-brew-green-tea", "inLanguage": "en", "datePublished": "2026-10-09T02:05:00.000Z", "dateModified": "2026-10-11T02:05:00.000Z", "wordCount": 69, "author": { "@type": "Person", "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publisher": { "@type": "Organization", "name": "Example Tea", "url": "https://example.com" }, "image": "https://example.com/images/green-tea.webp", "citation": [ "https://example.org/brewing" ] }, { "@type": "FAQPage", "@id": "https://example.com/blog/how-to-brew-green-tea#faq", "mainEntity": [ { "@type": "Question", "name": "Can I use boiling water?", "acceptedAnswer": { "@type": "Answer", "text": "You can, but the tea will taste bitter." } } ] }, { "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Example Tea", "item": "https://example.com" }, { "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://example.com/blog" }, { "@type": "ListItem", "position": 3, "name": "How to brew green tea", "item": "https://example.com/blog/how-to-brew-green-tea" } ] } ] } } ``` # Caching and limits > How the CitedSEO feed is cached, why a build never changes, and how to cache it on your side. The feed is built to be cached hard. Only one small file ever changes. ## What is cached, and for how long [Section titled “What is cached, and for how long”](#what-is-cached-and-for-how-long) | File | `cache-control` | Why | | ------------------------------- | ------------------------------------- | ------------------------------------------------------------------------ | | The manifest, `feed/index.json` | `public, max-age=60` | It points at the current build, so it is kept fresh. | | Everything under `b/{buildId}/` | `public, max-age=31536000, immutable` | A build is written once and never changes, so it can be cached for good. | A new article shows in the feed as soon as the manifest you hold is replaced: within a minute, or at once if you re-read it when a [webhook](/webhooks) arrives. ## Why builds never change [Section titled “Why builds never change”](#why-builds-never-change) Each publish writes a whole new build beside the old one, then points the manifest at it. So: * **You never see half a rebuild.** A reader part-way through an old build’s pages still gets a complete, consistent list. * **Stale reads are safe.** A cached page from an older build is still a true record of that moment. * **Old builds go away later.** They are kept for a while after a new one lands, then removed. Start from the manifest each time and you will never ask for one that is gone. ## The numbers [Section titled “The numbers”](#the-numbers) | | | | --------------- | ------------------------------------------------------------------- | | Articles a page | 20 (the manifest’s `pageSize`), newest first | | Paging | Pages `1` to `pageCount`, from the manifest | | Cross-origin | Any origin may read every file | | Key | None | | Rate limits | None to plan around. They are static files on Cloudflare’s network. | ## Caching it yourself [Section titled “Caching it yourself”](#caching-it-yourself) * **A static site** reads the feed at build time. Rebuild when a webhook arrives. * **A server-rendered site** can read the feed on each request and let `fetch` and the network do the caching, or hold the manifest for a minute and the build’s files for as long as it likes. * **In the browser**, the same caching applies. Rendering articles in the browser alone means search engines and AI assistants may not see them, so prefer the server. When the feed can't be reached Treat a failed fetch as “keep what you have”, not “there are no articles”. Serve your last good copy and try again, so a network blip never empties your blog. ## When a file isn’t there [Section titled “When a file isn’t there”](#when-a-file-isnt-there) | You asked for | `404` means | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | The manifest | The site ID is wrong, or the site has no feed. A site with nothing live still has a manifest, with `total` 0. | | A by-slug file | No live article has that slug. | | A page or by-slug file from a build you kept | That build is gone. Read the manifest again. | # The body as HTML > The elements and class names in an article's HTML body from the CitedSEO feed, and how to style them. `html` is the article’s body as plain, semantic HTML. It has no inline styles, no scripts and no wrapper element, so it takes on your site’s own typography once you put it inside your article container. ```html
{article.html}
``` ## It is safe to insert [Section titled “It is safe to insert”](#it-is-safe-to-insert) Text in the body is escaped and raw HTML is never passed through, so the body can’t carry a script. Links are kept only to an `http` or `https` address, a path on your site, or a place in the page. ## What it is made of [Section titled “What it is made of”](#what-it-is-made-of) Most of the body is ordinary elements: `p`, `h2` to `h4`, `ul`, `ol`, `table`, `blockquote`, `pre` and `code`, `hr`, `a`, `strong` and `em`. Every `h2` has an `id` made from its words, so you can link to a section or build your own table of contents. A few parts have a class name, so you can style them: | Part | Markup | What it is | | ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Answer | `p.citedseo-answer` | The short answer at the top of the article, in bold. | | Key takeaways | `div.citedseo-takeaways` | A “Key takeaways” line, then a list of three to five points. An article has this or the answer. | | Contents | `nav.citedseo-contents` | A “Contents” line, then a numbered list of links to each `h2`. Only when the site’s article settings have a contents list on, and the article has two or more sections. | | Callout | `aside.citedseo-callout` | A tip, note or warning set apart from the text. It also has one of `citedseo-note`, `citedseo-tip`, `citedseo-important`, `citedseo-warning` or `citedseo-caution`, and starts with its label in bold. | | Image | `figure` | An `img`, with a `figcaption` when it has a caption. | | Video | `figure.citedseo-video` | An `iframe` for a YouTube or Vimeo video. Any other video is a plain link. | | Quote | `blockquote` | The words quoted, then a line with who said them in a `cite`, when that is known. | | FAQ | `h2#faq` | “Frequently asked questions”, then an `h3` and a `p` for each question. | | Sources | `ol.citedseo-sources` | After an `h2#sources`, the pages the article’s facts came from, each a link and its publisher. | ## A starting stylesheet [Section titled “A starting stylesheet”](#a-starting-stylesheet) These are the only rules the class names need. Everything else follows your own article styles. article.css ```css .citedseo-answer { font-size: 1.125rem; } .citedseo-takeaways, .citedseo-contents, .citedseo-callout { margin-block: 1.5rem; padding: 1rem 1.25rem; border: 1px solid #e5e5e5; border-radius: 6px; } .citedseo-callout.citedseo-warning, .citedseo-callout.citedseo-caution { border-color: #f59e0b; } .citedseo-video iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; } .citedseo-sources { font-size: 0.875rem; } ``` Want your own components instead? If you’d rather draw a callout or a table with a component of yours, use [the Markdown body](/feed/markdown) and a Markdown renderer that lets you swap elements. # The body as Markdown > The Markdown body of an article from the CitedSEO feed, standard CommonMark with GitHub's extras, for sites that render with their own components. `markdown` is the same body as `html`, written as standard Markdown: CommonMark with GitHub’s extras (tables, strikethrough and alerts). Any Markdown tool that reads GitHub’s flavour reads it back as the same article. Use it when you want to draw the article with your own components, for example with MDX, `react-markdown`, `marked` or Astro’s renderer. ## An example [Section titled “An example”](#an-example) article.markdown ```md **Brew green tea at 80°C for two minutes. Hotter water makes it bitter.** ## Why temperature matters Boiling water pulls out more tannins, which taste **bitter**. See our [tea guide](/blog/tea-guide). > [!TIP] > No thermometer? Let boiled water stand for three minutes. 1. Heat the water to 80°C. 2. Steep for two minutes. 3. Pour and serve. | Tea | Temperature | | - | - | | Green | 80°C | | Black | 95°C | ## Frequently asked questions ### Can I use boiling water? You can, but the tea will taste bitter. ## Sources 1. [Brewing temperatures](https://example.org/brewing), Example Tea Board ``` ## How each part is written [Section titled “How each part is written”](#how-each-part-is-written) | Part | Markdown | | ------------- | --------------------------------------------------------------------------------------------------------- | | Answer | One bold paragraph at the top. | | Key takeaways | A bold “Key takeaways” line, then a list. | | Sections | `##` to `####` headings. | | Lists | `-` for bullets, `1.` for steps, sub-items indented one level. | | Tables | GitHub’s pipe tables. | | Callouts | GitHub’s alerts: a quote that starts `> [!TIP]`, `[!NOTE]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]`. | | Quotes | A `>` quote, then a line starting with a dash for who said it. | | Images | `![alt](src "caption")`, the caption as the image’s title. | | Videos | A link to the video, with its title as the words. | | Code | A fenced block with its language. The fence is longer than any fence inside it. | | FAQ | A “Frequently asked questions” `##`, then a `###` for each question. | | Sources | A “Sources” `##`, then a numbered list of links with their publishers. | ## What is different from the HTML [Section titled “What is different from the HTML”](#what-is-different-from-the-html) * **No contents list.** The HTML body can carry one; the Markdown never does, since most renderers make their own. * **No class names.** Callouts are GitHub’s alerts, so a renderer needs alert support (or a small plugin) to draw them as boxes. Without it they show as ordinary quotes. * **Videos are links.** There is no player in Markdown. The title and cover are left out of both, as in the HTML. # Structured data > The schema.org JSON-LD that comes with each article in the CitedSEO feed, and how to put it on your page. Each article comes with its schema.org structured data as JSON-LD, ready to put on the page. It is worked out from the version that is live, so it always says what the page shows. ## What it holds [Section titled “What it holds”](#what-it-holds) `structuredData` is one object with an `@graph` of up to three things: | Type | When | What it says | | ---------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BlogPosting` | Always | The headline, description, address, language, both dates, the word count, the author (a `Person`, or the site as an `Organization`), the publisher, the cover image and the pages the article cites. | | `FAQPage` | When the article has an FAQ | Each question and its answer, as plain text. | | `BreadcrumbList` | Always | The trail from your home page, through your blog, to the article. | article.structuredData ```json { "@context": "https://schema.org", "@graph": [ { "@type": "BlogPosting", "@id": "https://example.com/blog/how-to-brew-green-tea#article", "headline": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temperature matters and how to get it right without a thermometer.", "url": "https://example.com/blog/how-to-brew-green-tea", "mainEntityOfPage": "https://example.com/blog/how-to-brew-green-tea", "inLanguage": "en", "datePublished": "2026-10-09T02:05:00.000Z", "dateModified": "2026-10-11T02:05:00.000Z", "wordCount": 69, "author": { "@type": "Person", "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publisher": { "@type": "Organization", "name": "Example Tea", "url": "https://example.com" }, "image": "https://example.com/images/green-tea.webp", "citation": [ "https://example.org/brewing" ] }, { "@type": "FAQPage", "@id": "https://example.com/blog/how-to-brew-green-tea#faq", "mainEntity": [ { "@type": "Question", "name": "Can I use boiling water?", "acceptedAnswer": { "@type": "Answer", "text": "You can, but the tea will taste bitter." } } ] }, { "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Example Tea", "item": "https://example.com" }, { "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://example.com/blog" }, { "@type": "ListItem", "position": 3, "name": "How to brew green tea", "item": "https://example.com/blog/how-to-brew-green-tea" } ] } ] } ``` ## Put it on the page [Section titled “Put it on the page”](#put-it-on-the-page) Write it into a `script` tag of type `application/ld+json` in the page’s `head` or `body`. Escape `<` so nothing in an article can end the script early: ```js const jsonLd = JSON.stringify(article.structuredData).replaceAll('<', '\\u003c'); ``` ```html ``` Each [framework guide](/sdks/nextjs) shows where this goes. It can be null `structuredData` is `null` when structured data is switched off in the site’s article settings. Check before you write the tag. ## Keep it true [Section titled “Keep it true”](#keep-it-true) The addresses in the data are the article’s `url` and your site’s own address. If you show the article at a different address than `url`, the data no longer matches the page. Keep your route and the article’s `url` the same; the [SEO guide](/guides/seo) covers how. # Update the moment you publish > Use a CitedSEO webhook to rebuild a static site or clear a cache as soon as an article goes live, with the feed as the fallback. Without a webhook, your site shows a new article the next time it reads the feed. With one, it can show it within seconds. The pattern is the same everywhere: **verify, then refresh**. 1. **Make an endpoint** that [verifies the signature](/webhooks/verify). `webhookHandler` does it for you. 2. **Refresh** in the way your site needs: start a build, clear a cache, or revalidate a path. 3. **[Add the endpoint](/webhooks)** in the app and keep its secret in `CITEDSEO_WEBHOOK_SECRET`. ## Pick your refresh [Section titled “Pick your refresh”](#pick-your-refresh) `webhookHandler` is in [`@citedseo/feed`](/sdks) and every framework package. It checks the signature, so `onPost` only ever runs for a post from us, and it answers `500` when `onPost` throws, so a refresh that failed is tried again. * Static site Start a build through your host’s deploy hook. Keep the hook’s address in an environment variable; it is a secret too. ```ts import { webhookHandler } from '@citedseo/feed'; export const POST = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async () => { const res = await fetch(process.env.DEPLOY_HOOK_URL!, { method: 'POST' }); // Throwing answers 500, so CitedSEO tries the post again. if (!res.ok) throw new Error(`The deploy hook answered ${res.status}`); }, }); ``` * Next.js Revalidate the blog. Next.js builds the pages again on the next visit. app/api/citedseo/route.ts ```ts import { revalidatePath } from 'next/cache'; import { webhookHandler } from '@citedseo/next'; export const POST = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: () => revalidatePath('/blog', 'layout'), }); ``` * Your own cache Clear what you hold, or better, put the article from the post straight into it. The post carries the whole article, so there is nothing more to fetch. ```ts import { webhookHandler } from '@citedseo/feed'; import { articles } from './store'; export const POST = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async ({ data }) => { // Posts can arrive out of order, so keep whichever version changed last. const held = await articles.get(data.id); if (held && held.modifiedAt > data.modifiedAt) return; await articles.put(data.id, data); }, }); ``` ## Keep the feed as the fallback [Section titled “Keep the feed as the fallback”](#keep-the-feed-as-the-fallback) A webhook can be missed: your endpoint was down, or a deploy was mid-flight. Don’t let that leave your site stale for good. * **Still refresh on a timer.** Read the manifest again every few minutes to an hour, so a missed post heals itself. It is one small file. * **Catch take-downs from the feed.** Nothing is posted when an article is taken down, so a store built only from webhooks keeps it. Compare against the feed now and then and drop what is gone. One article a day A site ships one approved change a day unless someone publishes by hand, so a rebuild per webhook is not a lot of builds. # How publishing works > What happens when a CitedSEO article is published, when it appears in the feed, and how connected platforms and webhooks fit in. It helps to know what “published” means before you build on it. ## Publishing is a change of status [Section titled “Publishing is a change of status”](#publishing-is-a-change-of-status) An article in CitedSEO is written, checked and then approved, by you or by autopilot. Publishing is the moment an approved version is recorded as **live**. That is all it is: a status, with the version that is live and its address. Everything a developer sees follows from that one change. 1. **The version is recorded live.** The article now has a live version and an address on your site. 2. **The feed is rebuilt.** A new build of [the feed](/feed) is written with every live article at its live version, and the manifest is pointed at it. 3. **Connected platforms get it.** If the site is connected to a platform such as WordPress, the article is also sent there. This sits on top of the status change. It doesn’t replace the feed. 4. **Your webhooks are told,** after the rebuild. Each endpoint gets `article.published` when the article goes live, and `article.updated` for each change after. ## The feed is always there [Section titled “The feed is always there”](#the-feed-is-always-there) Every site has a feed, whatever else it publishes to. It is static files, so reading it never touches CitedSEO’s app or database. A site on WordPress has the same live articles in its feed as on WordPress, so you can use the feed for a second surface (an app, a docs site, a search index) without changing how the site publishes. ## When things publish [Section titled “When things publish”](#when-things-publish) A site ships one approved change a day, at its publish time, unless someone presses **Publish now** or picks a time. So expect events to arrive one at a time, not in bursts. The feed is also rebuilt when the site’s article settings or an author change, since those change what an article’s byline and structured data say. No webhook is sent for that. ## Updates and versions [Section titled “Updates and versions”](#updates-and-versions) A live article keeps being improved: refreshed sections, new internal links, a better title. Each is a new version that waits for approval like the first. * The feed keeps serving the **live** version until the new one ships. A draft never leaks into the feed. * When the new version ships, `modifiedAt` changes and your webhooks get `article.updated`. * The article’s `id`, `slug` and `url` stay the same through every update. ## Take-downs and undo [Section titled “Take-downs and undo”](#take-downs-and-undo) | In the app | In the feed | Webhook | | --------------------------------------- | -------------------------------------------------------- | ------------------- | | A shipped change is undone | A new build, with the article back to how it read before | `article.updated` | | A new article is taken down | A new build without the article | None | | A taken-down article is published again | A new build with the article back | `article.published` | Handle an article leaving Because nothing is posted for a take-down, a site that stores articles should treat the feed as the record: an article that is no longer in the feed is no longer live. # SEO for your article pages > What to put on a page that shows a CitedSEO article so search engines and AI assistants read it properly, from the canonical address to structured data. The feed gives you everything a well-formed article page needs. This is where each field goes. ## The checklist [Section titled “The checklist”](#the-checklist) | On the page | From the article | | ------------------------------ | ----------------------------------------------------- | | `` | `title` | | `<meta name="description">` | `description` | | `<link rel="canonical">` | `url` | | `<h1>` | `title`, once | | Cover image, with its alt text | `cover.src`, `cover.alt` | | Byline | `author.name`, linked to `author.url` when it has one | | Published and updated dates | `publishedAt`, `modifiedAt` | | JSON-LD | `structuredData`, when it isn’t `null` | | Open Graph tags | `title`, `description`, `url`, `cover.src` | ## Show the article at its `url` [Section titled “Show the article at its url”](#show-the-article-at-its-url) Every article has the address CitedSEO expects it to live at: your site, then your blog path, then its slug. The canonical address and the structured data both use it. So route by `slug` under the same path. If the feed says `https://example.com/blog/how-to-brew-green-tea`, serve the article at `/blog/how-to-brew-green-tea`. If your articles live somewhere else, say `/guides`, change the site’s **blog path** in the app’s settings so new articles get addresses under it. Don’t rewrite `url` on your side: the structured data would still point at the old address. An article's address doesn't change An article keeps the address it first went live at, through every update. Changing the blog path affects new articles only, so add redirects yourself if you move existing ones. ## Render on the server [Section titled “Render on the server”](#render-on-the-server) Draw articles on the server or at build time, not in the browser alone. A page that only fills in after JavaScript runs may be read as empty by search engines and by the AI assistants that cite pages. Every [framework guide](/sdks/nextjs) renders on the server. ## Keep the body whole [Section titled “Keep the body whole”](#keep-the-body-whole) The body is written to be quoted: an answer at the top, clear sections, an FAQ and a list of sources. Show all of it. * Keep the **answer** or **key takeaways** at the top, where they are. * Keep the **sources** list. It is what the article’s facts rest on. * Don’t drop the **FAQ** if you keep the structured data: the `FAQPage` data describes questions that must be visible on the page. ## One `h1` [Section titled “One h1”](#one-h1) The body starts at the first line of the article and uses `h2` and below, so your page’s `h1` is the `title` and there is exactly one. # Introduction > What a site's own code can do with CitedSEO, a read feed of your live articles and signed webhooks when one goes live or changes. CitedSEO writes and updates your site’s articles. These docs are for the developer who wants those articles on a site they build themselves: a Next.js app, an Astro site, anything that can make an HTTP request. There are two things to use, and neither needs an API key. ## The feed [Section titled “The feed”](#the-feed) Every article that is live on your site, as static JSON on `feeds.citedseo.ai`: ```txt feeds.citedseo.ai/{siteId}/feed/index.json the manifest feeds.citedseo.ai/{siteId}/b/{buildId}/page-1.json 20 articles a page feeds.citedseo.ai/{siteId}/b/{buildId}/by-slug/{slug}.json one article ``` They are plain files on Cloudflare’s network, rebuilt each time something publishes, so your site can read them as often as it likes. Each article comes whole: its title, search snippet, cover, byline, dates, the body as both HTML and Markdown, and its schema.org structured data. You fetch it and draw it with your own components. ## Webhooks [Section titled “Webhooks”](#webhooks) When an article goes live or changes, CitedSEO can post it to an endpoint of yours, signed so your code can tell the post came from us. Use it to rebuild a static site or clear a cache the moment something publishes, instead of waiting for the next fetch. ## Packages [Section titled “Packages”](#packages) For JavaScript and TypeScript there is a package for each: [`@citedseo/feed`](/sdks) reads the feed and checks webhooks anywhere, and `@citedseo/next`, `@citedseo/astro`, `@citedseo/react-router`, `@citedseo/nuxt` and `@citedseo/svelte` fit it to a framework. They are typed end to end. In any other language, the feed is plain JSON over HTTP. ## What is not here [Section titled “What is not here”](#what-is-not-here) * **No write API.** Articles are approved, changed and published in the CitedSEO app. The feed is read-only, and there is no API of ours for your site to call. * **No API keys.** Your site ID is the only thing the feed needs, and the feed holds only what is already public on your site. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) [Quickstart](/quickstart)Fetch your articles and show one, in about five minutes. [The article](/feed/article)Every field the feed and the webhooks give you. [Verify the signature](/webhooks/verify)Check that a webhook came from CitedSEO. [Set up with AI](/set-up-with-ai)Hand these docs to your coding agent. # Quickstart > Read your live articles from the CitedSEO feed and show one on your own site, with no key, over plain HTTP or with the JavaScript package. You need your [site ID](/site-id) and anything that can make an HTTP request. In JavaScript or TypeScript? [`@citedseo/feed`](/sdks) does these four steps in one call, typed: `await fetchArticles({ siteId })`. The steps below are what it does, and what to write in any other language. 1. **Read the manifest.** It names the current build. There is no key and no header to set. * curl ```sh curl https://feeds.citedseo.ai/YOUR_SITE_ID/feed/index.json ``` * JavaScript ```js const feed = 'https://feeds.citedseo.ai/YOUR_SITE_ID'; const manifest = await fetch(`${feed}/feed/index.json`).then((res) => res.json()); ``` * Python ```python import requests feed = "https://feeds.citedseo.ai/YOUR_SITE_ID" manifest = requests.get(f"{feed}/feed/index.json", timeout=10).json() ``` 200 OK ```json { "buildId": "01JA2M7Q4K8ZP3", "total": 42, "pageCount": 3, "pageSize": 20, "latestModifiedAt": "2026-10-11T02:05:00.000Z", "generatedAt": "2026-10-11T02:05:04.000Z" } ``` 2. **Read the pages of articles** from that build. They are numbered from 1 to the manifest’s `pageCount`, which is 0 when nothing is live, and hold 20 articles each, newest first. * curl ```sh curl https://feeds.citedseo.ai/YOUR_SITE_ID/b/BUILD_ID/page-1.json ``` * JavaScript ```js const articles = []; for (let n = 1; n <= manifest.pageCount; n++) { const page = await fetch(`${feed}/b/${manifest.buildId}/page-${n}.json`).then((res) => res.json()); articles.push(...page.articles); } ``` * Python ```python articles = [] for n in range(1, manifest["pageCount"] + 1): page = requests.get(f"{feed}/b/{manifest['buildId']}/page-{n}.json", timeout=10).json() articles.extend(page["articles"]) ``` 200 OK ```json { "articles": [ { "id": "0b6f6c1e-3f0a-4a59-9d0e-2f8f4c1f7a31", "slug": "how-to-brew-green-tea", "url": "https://example.com/blog/how-to-brew-green-tea", "title": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temp…", "cover": { "src": "https://example.com/images/green-tea.webp", "alt": "A glass teapot of green tea" }, "author": { "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publishedAt": "2026-10-09T02:05:00.000Z", "modifiedAt": "2026-10-11T02:05:00.000Z", "html": "<p class=\"citedseo-answer\"><strong>Brew green tea at 80°C for two minutes. Hot…", "markdown": "**Brew green tea at 80°C for two minutes. Hotter water makes…", "structuredData": { "@context": "https://schema.org", "@graph": [ "…" ] } } ] } ``` 3. **List your articles.** Each has a `slug`, the last part of its address, to route by. ```js for (const article of articles) { console.log(`/blog/${article.slug}`, article.title); } ``` 4. **Show one.** Read it by its slug, used as it is: an article’s `slug` is already URL-encoded. The body comes as HTML and as Markdown, without the title or cover, so you place those yourself. ```js const article = articles[0]; if (!article) throw new Error('Nothing is live yet'); const res = await fetch(`${feed}/b/${manifest.buildId}/by-slug/${article.slug}.json`); if (res.status === 404) throw new Error('No such article'); if (!res.ok) throw new Error(`The feed answered ${res.status}`); const { article: whole } = await res.json(); document.querySelector('h1').textContent = whole.title; document.querySelector('article').innerHTML = whole.html; ``` Always start from the manifest The build’s name changes with every publish, so never keep one in your code. Read the manifest, then read from the build it names. ## Next [Section titled “Next”](#next) [Your framework](/sdks/nextjs)A blog index and article page in Next.js, Astro, React Router, Nuxt or SvelteKit. [The article](/feed/article)Every field, with a full example. [Update the moment you publish](/guides/instant-updates)Use a webhook instead of waiting for the next read. # JavaScript and TypeScript > The @citedseo/feed package reads your live CitedSEO articles and checks webhooks in any JavaScript runtime, with the feed's types built in. `@citedseo/feed` is the feed and the webhook check as a package: a few functions over `fetch`, with no dependencies and no key. It runs in Node.js 20+, Cloudflare Workers, Deno, Bun and the browser, and it is what each framework package is built on. * npm ```sh npm install @citedseo/feed ``` * pnpm ```sh pnpm add @citedseo/feed ``` * yarn ```sh yarn add @citedseo/feed ``` * bun ```sh bun add @citedseo/feed ``` ## Read your articles [Section titled “Read your articles”](#read-your-articles) ```ts import { fetchArticle, fetchArticles } from '@citedseo/feed'; const articles = await fetchArticles({ siteId: 'YOUR_SITE_ID' }); const article = await fetchArticle({ siteId: 'YOUR_SITE_ID', slug: 'how-to-brew-green-tea' }); ``` | Function | Gives | What it reads | | ------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------- | | `fetchArticles(options)` | `FeedArticle[]`, newest first | The manifest, then every page of the build it names. | | `fetchArticle(options)` | `FeedArticle \| null` | The manifest, then one small file. `null` when no article has that slug. | | `fetchManifest(options)` | `FeedManifest` | The manifest alone, to see whether anything changed. | | `jsonLd(article)` | `string \| null` | Nothing. It gives the article’s structured data as text that is safe inside a script tag. | ### Options [Section titled “Options”](#options) ```ts /** What every read takes. */ export type FeedOptions = { /** Your site ID. It is the only thing the feed needs. */ siteId: string /** Another origin to read from, for tests. Defaults to https://feeds.citedseo.ai. */ origin?: string /** Your own fetch: a framework's caching one, or a test's. Defaults to the global fetch. */ fetch?: typeof fetch } ``` Pass `fetch` to read through a cache of your own, such as SvelteKit’s `fetch` in a `load` function. ## Types [Section titled “Types”](#types) Every function is typed, and the types are exported. They are the ones CitedSEO’s own engine builds the feed against, so they can’t fall behind it. ```ts import type { FeedArticle, FeedManifest, WebhookEvent, WebhookPost } from '@citedseo/feed'; ``` [The article](/feed/article) prints them in full. ## Check a webhook [Section titled “Check a webhook”](#check-a-webhook) ```ts import { webhookHandler } from '@citedseo/feed'; export const POST = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async (post) => { // post.event is "article.published" or "article.updated"; post.data is the article. }, }); ``` | Function | What it does | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `webhookHandler({ secret, onPost })` | A whole route: answers `400` to a post that isn’t ours, runs `onPost` for one that is, answers `204`, or `500` when `onPost` throws so the post is tried again. | | `readWebhook(request, secret)` | The verified post as a `WebhookPost`, or `null`. | | `verifyWebhook(secret, body, header)` | Whether a signature header is ours for a raw body. | See [Verify the signature](/webhooks/verify) for what the check does. ## Errors [Section titled “Errors”](#errors) A read that fails throws a `CitedSEOError` with a `code` to branch on: | `code` | When | | ------------------ | ------------------------------------------------------- | | `CONFIG` | The site ID or slug is missing. | | `NOT_FOUND` | There is no feed for that site ID. | | `NETWORK_ERROR` | The feed couldn’t be reached. | | `SERVER_ERROR` | The feed answered with an error; `status` has the code. | | `INVALID_RESPONSE` | A file wasn’t the shape it should be. | An article that isn’t there is not an error: `fetchArticle` gives `null`. ## Your framework [Section titled “Your framework”](#your-framework) Each of these re-exports everything above, so one import covers a site. [Next.js](/sdks/nextjs)@citedseo/next: a cached reader and article metadata. [Astro](/sdks/astro)@citedseo/astro: a content loader. [React Router](/sdks/react-router)@citedseo/react-router: readers for loaders, and meta. [Nuxt](/sdks/nuxt)@citedseo/nuxt: readers for server routes, and head tags. [SvelteKit](/sdks/sveltekit)@citedseo/svelte: readers for load functions, and head tags. # Astro > Show your CitedSEO articles in an Astro site with @citedseo/astro, a content loader that fills a collection from the feed, plus a blog index, article pages and JSON-LD. `@citedseo/astro` is a content loader. Your articles become a collection, typed and queried like any other in your site, and each one renders with `render()`. It needs Astro 6 or later. Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is <YOUR_SITE_ID>. Install @citedseo/astro. Add a content collection that loads my articles, plus a /blog index and /blog/[slug] pages with the article's JSON-LD in this Astro app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ## Install [Section titled “Install”](#install) * npm ```sh npm install @citedseo/astro ``` * pnpm ```sh pnpm add @citedseo/astro ``` * yarn ```sh yarn add @citedseo/astro ``` * bun ```sh bun add @citedseo/astro ``` ## What you’ll add [Section titled “What you’ll add”](#what-youll-add) * .env * src * content.config.ts the collection * pages * blog * index.astro the index * \[slug].astro one article 1. **Set your site ID.** .env ```sh CITEDSEO_SITE_ID=YOUR_SITE_ID ``` 2. **Load the feed as a collection.** The loader brings its own schema, so there is none to write. src/content.config.ts ```ts import { defineCollection } from 'astro:content'; import { citedseoLoader } from '@citedseo/astro'; const articles = defineCollection({ loader: citedseoLoader({ siteId: import.meta.env.CITEDSEO_SITE_ID }), }); export const collections = { articles }; ``` 3. **Add the index.** src/pages/blog/index.astro ```astro --- import { getCollection } from 'astro:content'; const articles = (await getCollection('articles')).sort( (a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf(), ); --- <ul> { articles.map((article) => ( <li> <a href={`/blog/${article.id}`}>{article.data.title}</a> <p>{article.data.description}</p> </li> )) } </ul> ``` 4. **Add the article page.** Each entry’s `id` is the article’s slug, and `render()` gives its body. src/pages/blog/\[slug].astro ```astro --- import { getCollection, render } from 'astro:content'; import { jsonLd } from '@citedseo/astro'; export async function getStaticPaths() { const articles = await getCollection('articles'); // An article's slug is URL-encoded; Astro encodes a param itself, so hand it over decoded. return articles.map((article) => ({ params: { slug: decodeURIComponent(article.id) }, props: { article }, })); } const { article } = Astro.props; const { Content } = await render(article); const data = jsonLd(article.data); --- <html lang="en"> <head> <title>{article.data.title} {data && ``` 4. **Add the article page.** `articleHead` gives the title, description, canonical link, Open Graph tags and JSON-LD in the shape `useHead` takes. pages/blog/\[slug].vue ```vue ``` ## When you publish [Section titled “When you publish”](#when-you-publish) Each request reads the manifest, which is cached for a minute, so a new article shows within a minute with no webhook. If you prerender the blog or cache your pages, refresh from a server route. `webhookHandler` checks the signature for you: server/api/citedseo.post.ts ```ts import { webhookHandler } from '@citedseo/nuxt'; const handle = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async (post) => { // Clear your cache, start a build, or store post.data. }, }); export default defineEventHandler((event) => handle(toWebRequest(event))); ``` See [Update the moment you publish](/guides/instant-updates). ## Keep the address the same [Section titled “Keep the address the same”](#keep-the-address-the-same) Each article’s `url` is where CitedSEO expects it to live, and its structured data and canonical address say so. If your blog isn’t at `/blog`, set the site’s blog path in the app’s settings to match your route. See [SEO for your article pages](/guides/seo). # React Router > Show your CitedSEO articles in a React Router v7 app with @citedseo/react-router, with loaders, meta and JSON-LD. `@citedseo/react-router` gives your loaders the feed and your routes their meta tags. Two routes with server loaders, in React Router v7’s framework mode. It needs React Router 7.9 or later, the first version that escapes JSON-LD in `meta`. Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/react-router. Add blog._index and blog.$slug routes with loaders and meta in this React Router v7 app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ## Install [Section titled “Install”](#install) * npm ```sh npm install @citedseo/react-router ``` * pnpm ```sh pnpm add @citedseo/react-router ``` * yarn ```sh yarn add @citedseo/react-router ``` * bun ```sh bun add @citedseo/react-router ``` ## What you’ll add [Section titled “What you’ll add”](#what-youll-add) * .env * app * routes.ts your routes * routes * blog.\_index.tsx the index * blog.$slug.tsx one article * api.citedseo.ts the webhook 1. **Set your site ID.** .env ```sh CITEDSEO_SITE_ID=YOUR_SITE_ID ``` 2. **Add the index.** app/routes/blog.\_index.tsx ```tsx import { fetchArticles } from '@citedseo/react-router'; import { Link } from 'react-router'; import type { Route } from './+types/blog._index'; export async function loader() { const articles = await fetchArticles({ siteId: process.env.CITEDSEO_SITE_ID! }); return { articles: articles.map(({ id, slug, title, description }) => ({ id, slug, title, description })), }; } export default function Blog({ loaderData }: Route.ComponentProps) { return (
    {loaderData.articles.map((article) => (
  • {article.title}

    {article.description}

  • ))}
); } ``` 3. **Add the article route.** `articleMeta` gives the title, description, canonical link, Open Graph tags and JSON-LD. app/routes/blog.$slug.tsx ```tsx import { articleMeta, fetchArticle } from '@citedseo/react-router'; import { data } from 'react-router'; import type { Route } from './+types/blog.$slug'; export async function loader({ params }: Route.LoaderArgs) { const article = await fetchArticle({ siteId: process.env.CITEDSEO_SITE_ID!, slug: params.slug, }); if (!article) throw data(null, { status: 404 }); return { article }; } export function meta({ data }: Route.MetaArgs) { return data ? articleMeta(data.article) : []; } export default function ArticlePage({ loaderData }: Route.ComponentProps) { const { article } = loaderData; return (

{article.title}

{article.cover && {article.cover.alt}}
); } ``` 4. **Register the routes.** If your app lists its routes in `app/routes.ts`, add these three. With `@react-router/fs-routes` and `flatRoutes()`, the file names above are found without it. app/routes.ts ```ts import { type RouteConfig, route } from '@react-router/dev/routes'; export default [ // …your other routes route('blog', 'routes/blog._index.tsx'), route('blog/:slug', 'routes/blog.$slug.tsx'), route('api/citedseo', 'routes/api.citedseo.ts'), ] satisfies RouteConfig; ``` ## When you publish [Section titled “When you publish”](#when-you-publish) Each request reads the manifest, which is cached for a minute, so a new article shows within a minute with no webhook at all. If you add your own cache in front of your pages, clear it from a resource route. `webhookHandler` checks the signature for you: app/routes/api.citedseo.ts ```ts import { webhookHandler } from '@citedseo/react-router'; import type { Route } from './+types/api.citedseo'; const handle = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async (post) => { // Clear your cache, or store post.data. }, }); export const action = ({ request }: Route.ActionArgs) => handle(request); ``` See [Update the moment you publish](/guides/instant-updates). ## Keep the address the same [Section titled “Keep the address the same”](#keep-the-address-the-same) Each article’s `url` is where CitedSEO expects it to live, and its structured data and canonical address say so. If your blog isn’t at `/blog`, set the site’s blog path in the app’s settings to match your route. See [SEO for your article pages](/guides/seo). # SvelteKit > Show your CitedSEO articles in a SvelteKit site with @citedseo/svelte, with server load functions, a blog index, article pages and JSON-LD. `@citedseo/svelte` gives your `load` functions the feed and your pages their head tags. Two routes with server `load` functions. Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/svelte. Add /blog and /blog/[slug] routes with server load functions and the article's JSON-LD in svelte:head in this SvelteKit app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ## Install [Section titled “Install”](#install) * npm ```sh npm install @citedseo/svelte ``` * pnpm ```sh pnpm add @citedseo/svelte ``` * yarn ```sh yarn add @citedseo/svelte ``` * bun ```sh bun add @citedseo/svelte ``` ## What you’ll add [Section titled “What you’ll add”](#what-youll-add) * .env * src * routes * blog * +page.server.ts * +page.svelte the index * \[slug] * +page.server.ts * +page.svelte one article * api * citedseo * +server.ts the webhook 1. **Set your site ID.** .env ```sh CITEDSEO_SITE_ID=YOUR_SITE_ID ``` 2. **Add the index.** src/routes/blog/+page.server.ts ```ts import { fetchArticles } from '@citedseo/svelte'; import { CITEDSEO_SITE_ID } from '$env/static/private'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ fetch }) => { const articles = await fetchArticles({ siteId: CITEDSEO_SITE_ID, fetch }); return { articles: articles.map(({ id, slug, title, description }) => ({ id, slug, title, description })), }; }; ``` src/routes/blog/+page.svelte ```svelte
    {#each data.articles as article (article.id)}
  • {article.title}

    {article.description}

  • {/each}
``` 3. **Add the article page.** `articleHead` gives the title, description, canonical address, Open Graph tags and the JSON-LD script tag. src/routes/blog/\[slug]/+page.server.ts ```ts import { articleHead, fetchArticle } from '@citedseo/svelte'; import { error } from '@sveltejs/kit'; import { CITEDSEO_SITE_ID } from '$env/static/private'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ params, fetch }) => { const article = await fetchArticle({ siteId: CITEDSEO_SITE_ID, slug: params.slug, fetch }); if (!article) error(404, 'Article not found'); return { article, head: articleHead(article) }; }; ``` src/routes/blog/\[slug]/+page.svelte ```svelte {head.title} {#each head.meta as tag} {/each} {#if head.jsonLdTag} {@html head.jsonLdTag} {/if}

{article.title}

{#if article.cover} {article.cover.alt} {/if} {@html article.html}
``` ## Prerender, or render on the server [Section titled “Prerender, or render on the server”](#prerender-or-render-on-the-server) To build the blog as static pages, add `export const prerender = true;` to both `+page.server.ts` files, and rebuild when a webhook arrives. On a server, each page view reads the manifest and one small file, both cached by the network, so a new article shows within a minute. See [Caching and limits](/feed/caching). Either way, `webhookHandler` checks the signature of a post for you: src/routes/api/citedseo/+server.ts ```ts import { webhookHandler } from '@citedseo/svelte'; import { CITEDSEO_WEBHOOK_SECRET } from '$env/static/private'; import type { RequestHandler } from './$types'; const handle = webhookHandler({ secret: CITEDSEO_WEBHOOK_SECRET, onPost: async (post) => { // Start a build, clear your cache, or store post.data. }, }); export const POST: RequestHandler = ({ request }) => handle(request); ``` See [Update the moment you publish](/guides/instant-updates). ## Keep the address the same [Section titled “Keep the address the same”](#keep-the-address-the-same) Each article’s `url` is where CitedSEO expects it to live, and its structured data and canonical address say so. If your blog isn’t at `/blog`, set the site’s blog path in the app’s settings to match your route. See [SEO for your article pages](/guides/seo). # Set up with AI > Hand these docs and your site ID to a coding agent, and let it add the blog pages to your site. The whole developer surface is one feed, one webhook and a small package for each framework, which makes it a good job for a coding agent. These docs are published in a form an agent can read in one go. ## The files an agent reads [Section titled “The files an agent reads”](#the-files-an-agent-reads) | File | What it holds | | ------------------------------------ | --------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | A short map of these docs, with links. | | [`/llms-full.txt`](/llms-full.txt) | Every page in one file. Point an agent here when you want it to write code. | | [`/llms-small.txt`](/llms-small.txt) | The same, trimmed for a smaller context. | ## What to give it [Section titled “What to give it”](#what-to-give-it) Two things: 1. **The docs:** `https://developer.citedseo.ai/llms-full.txt`. 2. **Your [site ID](/site-id).** There is no key to hand over. ## A prompt per framework [Section titled “A prompt per framework”](#a-prompt-per-framework) Copy one, put your site ID in, and paste it into Claude Code, Cursor or any coding agent. ### Next.js [Section titled “Next.js”](#nextjs) Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/next. Add a /blog index and /blog/[slug] article pages, with generateMetadata and the article's JSON-LD in this Next.js App Router app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ### Astro [Section titled “Astro”](#astro) Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/astro. Add a content collection that loads my articles, plus a /blog index and /blog/[slug] pages with the article's JSON-LD in this Astro app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ### React Router [Section titled “React Router”](#react-router) Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/react-router. Add blog._index and blog.$slug routes with loaders and meta in this React Router v7 app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ### Nuxt [Section titled “Nuxt”](#nuxt) Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/nuxt. Add pages/blog/index.vue and pages/blog/[slug].vue with the article's head tags and JSON-LD in this Nuxt app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` ### SvelteKit [Section titled “SvelteKit”](#sveltekit) Hand this to your coding agent Copy ``` Read https://developer.citedseo.ai/llms-full.txt. My CitedSEO site ID is . Install @citedseo/svelte. Add /blog and /blog/[slug] routes with server load functions and the article's JSON-LD in svelte:head in this SvelteKit app, reading my articles with that package. The feed needs no key. Use only the functions and addresses that file lists. ``` Why the prompt names the package An agent left to guess will often make up an import. The prompt names the package for your framework, and the docs file lists every function it exports. # Your site ID > Where to find your CitedSEO site ID, and why it is safe to use in the browser. Your site ID names one site in CitedSEO. It is the only thing the feed needs. ## Where to find it [Section titled “Where to find it”](#where-to-find-it) In the CitedSEO app, open **Settings**, then **Integrations**, then **Publishing**. The feed address is shown there with a copy button. Your site ID is the first part of its path: GET`https://feeds.citedseo.ai/{siteId}/feed/index.json` Each site has its own ID, so a workspace with several sites has several feeds. ## It is a read key, not a secret [Section titled “It is a read key, not a secret”](#it-is-a-read-key-not-a-secret) The feed holds only your live articles, which are already public on your site, and the ID is a long random value nobody can guess. Reading the feed gives nobody anything they couldn’t get from your blog, so: * You can read the feed from the browser. It answers any origin. * You can commit the ID to your repository or put it in a public environment variable. * There is nothing to rotate. A webhook secret is different The signing secret you get when you add a webhook endpoint (`whsec_…`) **is** a secret. Keep it on the server and out of your repository. See [Verify the signature](/webhooks/verify). ## Keep it in one place [Section titled “Keep it in one place”](#keep-it-in-one-place) Put the ID in an environment variable so the feed address is built once: .env ```sh CITEDSEO_SITE_ID=YOUR_SITE_ID ``` The framework guides all read it from `CITEDSEO_SITE_ID`. # How webhooks work > CitedSEO posts each article to your endpoint when it goes live or changes, signed with a secret only you and CitedSEO hold. A webhook is CitedSEO telling your code that something published. Each time an article goes live or its live version changes, we post the article to every endpoint you have added, as JSON, signed with that endpoint’s own secret. It works whatever your site publishes to. A site on WordPress can still add a webhook, to hook its own tools in. ## Add an endpoint [Section titled “Add an endpoint”](#add-an-endpoint) 1. In the CitedSEO app, open **Settings**, then **Integrations**, then **Webhooks**. 2. Add your endpoint’s address. It must be `https` and on the public internet: an `http` address, `localhost` or a private network address is refused. 3. Copy the signing secret. It starts with `whsec_` and is shown **once**. Keep it on your server, for example as `CITEDSEO_WEBHOOK_SECRET`. Lost the secret? It can’t be shown again. Add the same address once more and it gets a new secret, which replaces the old one. Each endpoint has its own secret, and a site can have more than one endpoint. Removing an endpoint in the app stops its posts at once. ## What your endpoint does [Section titled “What your endpoint does”](#what-your-endpoint-does) 1. **Read the raw body** as text, before any JSON parsing. 2. **[Verify the signature](/webhooks/verify)** with your secret. Turn away anything that doesn’t verify. 3. **Answer with a `2xx` quickly**, within 15 seconds. Do slow work after you answer. 4. **Act on it:** rebuild, clear a cache, or store the article. The post carries the whole article. The feed is rebuilt before the post is sent, so a re-read normally sees the new build; if that rebuild failed, the post is still sent and the feed catches up on its next rebuild, so use the article in the post when you need to be sure. ## Testing on your own machine [Section titled “Testing on your own machine”](#testing-on-your-own-machine) An endpoint must be on the public internet, so `localhost` can’t be added. Use a tunnel (Cloudflare Tunnel, ngrok and similar give you an `https` address that forwards to your machine) and add the tunnel’s address as an endpoint while you build. ## Next [Section titled “Next”](#next) [Events and payload](/webhooks/events)The two events, the headers and the JSON body. [Verify the signature](/webhooks/verify)Copy-paste checks for JavaScript, Python and PHP. [Retries and duplicates](/webhooks/retries)What happens when your endpoint is down. # Events and payload > The two CitedSEO webhook events, article.published and article.updated, with their headers and JSON body. ## Events [Section titled “Events”](#events) There are two. | Event | Sent when | | ------------------- | -------------------------------------------------------------------------------------------------- | | `article.published` | An article goes live: the first time, or again after it was taken down. | | `article.updated` | An article that is already live changes: an update, new links, a new title, or an undo that ships. | There is no event for a take-down When an article is taken down, nothing is posted. The [feed](/feed) is rebuilt without it, so a site that needs to notice should read the feed. ## The request [Section titled “The request”](#the-request) Each event is one `POST` to your endpoint with a JSON body. ### Headers [Section titled “Headers”](#headers) * `content-type`application/json * `citedseo-event`string The event’s name, the same as `event` in the body. * `citedseo-delivery`string This delivery’s ID, the same as `delivery` in the body. It is the same on every try of the same post. See [Retries and duplicates](/webhooks/retries). * `citedseo-signature`string `t=,v1=`. See [Verify the signature](/webhooks/verify). ### Body [Section titled “Body”](#body) * `event`string `article.published` or `article.updated`. * `delivery`string This delivery’s ID. Treat it as an opaque string. * `data`Article The [article](/feed/article) as it is live now, in the same shape the feed serves. POST your endpoint ```json { "event": "article.published", "delivery": "5d1c3b0a-8e42-4f6b-9a57-0c2f1e9b7d44:b7a1f3c2-6d58-4e0a-8c39-41f2a9d05e16", "data": { "id": "0b6f6c1e-3f0a-4a59-9d0e-2f8f4c1f7a31", "slug": "how-to-brew-green-tea", "url": "https://example.com/blog/how-to-brew-green-tea", "title": "How to brew green tea", "description": "Brew green tea at 80°C for two minutes. Here is why the temp…", "cover": { "src": "https://example.com/images/green-tea.webp", "alt": "A glass teapot of green tea" }, "author": { "name": "Sam Lee", "url": "https://example.com/authors/sam-lee" }, "publishedAt": "2026-10-09T02:05:00.000Z", "modifiedAt": "2026-10-11T02:05:00.000Z", "html": "

Brew green tea at 80°C for two minutes. Hot…", "markdown": "**Brew green tea at 80°C for two minutes. Hotter water makes…", "structuredData": { "@context": "https://schema.org", "@graph": [ "…" ] } } } ``` The long fields are cut short above. A real post carries the whole article. ## TypeScript [Section titled “TypeScript”](#typescript) The types come with [the packages](/sdks): ```ts import type { WebhookEvent, WebhookPost } from '@citedseo/feed'; ``` @citedseo/feed ```ts /** What every read takes. */ export type FeedOptions = { /** Your site ID. It is the only thing the feed needs. */ siteId: string /** Another origin to read from, for tests. Defaults to https://feeds.citedseo.ai. */ origin?: string /** Your own fetch: a framework's caching one, or a test's. Defaults to the global fetch. */ fetch?: typeof fetch } /** What every read takes. */ export type FeedOptions = { /** Your site ID. It is the only thing the feed needs. */ siteId: string /** Another origin to read from, for tests. Defaults to https://feeds.citedseo.ai. */ origin?: string /** Your own fetch: a framework's caching one, or a test's. Defaults to the global fetch. */ fetch?: typeof fetch } ``` # Retries and duplicates > What CitedSEO does when your webhook endpoint is down or slow, and how to handle the same delivery arriving twice. ## What your answer means [Section titled “What your answer means”](#what-your-answer-means) | Your endpoint | What happens | | --------------------------------------------------------- | ---------------------------------------------------------------------------- | | Answers `2xx` | Delivered. Nothing more is sent for this delivery. | | Doesn’t answer within 15 seconds, or the connection fails | Tried again. | | Answers `408`, `429` or any `5xx` | Tried again. | | Answers any other `4xx` | Not tried again. Your endpoint said no. | | Answers with a redirect (`3xx`) | Not followed, and not tried again. A post goes only to the address you gave. | A post is tried again a few times, with a wait between tries. If your endpoint never takes it, we let it go: the article is live either way, and the [feed](/feed) still has it. A webhook is a nudge, not the record Don’t rely on webhooks alone to know what is live. The feed is the record. If your endpoint was down for a while, fetch the feed to catch up. ## The same delivery twice [Section titled “The same delivery twice”](#the-same-delivery-twice) Because a post can be tried again, your endpoint can get the same delivery more than once, for example when it did the work but answered too slowly. Every try of the same post has the same `delivery` ID, in the body and in the `citedseo-delivery` header. To do the work once, remember the IDs you have handled: ```ts export async function POST(request: Request) { const body = await request.text(); // …verify the signature first… const post = JSON.parse(body); if (await seen(post.delivery)) return new Response(null, { status: 204 }); await handle(post); // Only once the work is done, so a try that failed is done again on the next. await remember(post.delivery); return new Response(null, { status: 204 }); } ``` A try is sent again after 15 seconds with no answer, so two tries of one delivery can overlap when the work is slow, and both get past `seen`. Make `handle` safe to run twice, for example by keeping whichever version of an article has the later `modifiedAt`, or claim the ID in one atomic step in your store and release it if the work fails. Often you don’t need even that. If all you do is rebuild or clear a cache, doing it twice is harmless. ## Answer first, work after [Section titled “Answer first, work after”](#answer-first-work-after) You have 15 seconds to answer. A rebuild or a slow call to another service can take longer, and a slow answer is tried again. Verify, queue the work, answer `2xx`, then do the work. ## Order [Section titled “Order”](#order) Each article’s events are sent as it publishes, and each post carries the article as it is live at that moment. If two posts for the same article arrive close together, keep the one with the later `modifiedAt`. # Verify the signature > Check that a webhook came from CitedSEO by verifying its HMAC-SHA256 signature, with the SDK or your own code in Node.js, Python and PHP. Anyone who learns your endpoint’s address can post to it. The signature is how your code tells a post from CitedSEO from any other. Check it on every request, before you trust anything in the body. ## The signature [Section titled “The signature”](#the-signature) Every post has a `citedseo-signature` header: ```http citedseo-signature: t=1791079500,v1=5f2b6c0e9a… ``` * `t` is when we signed the post, in Unix seconds. * `v1` is an HMAC-SHA256, in hex, of the text `.` keyed with your endpoint’s signing secret, where `` is the request body exactly as sent. It is the scheme Stripe’s webhooks use, so if you have verified one of those, this is the same check. ## The check [Section titled “The check”](#the-check) 1. Read the request body as **raw text**. Don’t parse it and write it out again: a re-serialised body won’t match. 2. Split the header into `t` and `v1`. 3. Refuse the post if `t` is more than **five minutes** from now. That stops an old post from being replayed. 4. Work out the HMAC-SHA256 of `.` with your secret, in hex. 5. Compare it to `v1` in constant time. If they differ, answer `400` and stop. ## Code [Section titled “Code”](#code) * JavaScript and TypeScript [`@citedseo/feed`](/sdks) does the whole check, on Web Crypto, so it runs in Node.js 20+, Cloudflare Workers, Deno, Bun, and Vercel and Netlify functions. Every framework package exports the same functions. `webhookHandler` is a whole route for anything that takes a standard `Request`: webhook.ts ```ts import { webhookHandler } from '@citedseo/feed'; export const POST = webhookHandler({ secret: process.env.CITEDSEO_WEBHOOK_SECRET!, onPost: async ({ event, data }) => { // event is "article.published" or "article.updated"; data is the article. }, }); ``` It answers `400` to a post that isn’t ours, `204` once `onPost` is done, and `500` when `onPost` throws, so the post is tried again. To answer yourself, use `readWebhook`, which gives the verified post or `null`: webhook.ts ```ts import { readWebhook } from '@citedseo/feed'; export async function POST(request: Request) { const post = await readWebhook(request, process.env.CITEDSEO_WEBHOOK_SECRET!); if (!post) return new Response('Bad signature', { status: 400 }); // post.event, post.delivery, post.data return new Response(null, { status: 204 }); } ``` And where you have the raw body and the header some other way, `verifyWebhook(secret, body, header)` gives `true` or `false`. * Node.js, by hand Without the package, on Node’s own `crypto`: verify.js ```js import { createHmac, timingSafeEqual } from 'node:crypto'; const tolerance = 300; // seconds export function verifyCitedSEO(secret, body, header) { const parts = Object.fromEntries((header ?? '').split(',').map((part) => part.split('=', 2))); const t = Number(parts.t); if (!Number.isInteger(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > tolerance) return false; const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')); const given = Buffer.from(parts.v1); return expected.length === given.length && timingSafeEqual(expected, given); } ``` With Express, read the body raw on this route: server.js ```js import express from 'express'; import { verifyCitedSEO } from './verify.js'; const app = express(); app.post('/webhooks/citedseo', express.text({ type: 'application/json' }), (req, res) => { const ok = verifyCitedSEO( process.env.CITEDSEO_WEBHOOK_SECRET, req.body, req.get('citedseo-signature'), ); if (!ok) return res.status(400).send('Bad signature'); const { event, data } = JSON.parse(req.body); res.sendStatus(204); }); ``` * Python verify.py ```python import hashlib import hmac import time TOLERANCE = 300 # seconds def verify_citedseo(secret: str, body: bytes, header: str | None) -> bool: parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part) try: t = int(parts["t"]) given = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > TOLERANCE: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, given) ``` With Flask: app.py ```python import json import os from flask import Flask, request from verify import verify_citedseo app = Flask(__name__) @app.post("/webhooks/citedseo") def citedseo(): body = request.get_data() header = request.headers.get("citedseo-signature") if not verify_citedseo(os.environ["CITEDSEO_WEBHOOK_SECRET"], body, header): return "Bad signature", 400 post = json.loads(body) # post["event"], post["data"] return "", 204 ``` * PHP webhook.php ```php TOLERANCE) { return false; } $expected = hash_hmac('sha256', $t . '.' . $body, $secret); return hash_equals($expected, $given); } $body = file_get_contents('php://input'); $header = $_SERVER['HTTP_CITEDSEO_SIGNATURE'] ?? null; if (!verify_citedseo(getenv('CITEDSEO_WEBHOOK_SECRET'), $body, $header)) { http_response_code(400); exit('Bad signature'); } $post = json_decode($body, true); // $post['event'], $post['data'] http_response_code(204); ``` ## When it doesn’t verify [Section titled “When it doesn’t verify”](#when-it-doesnt-verify) Check these first * **The body was parsed.** A framework that parses JSON for you has already changed the bytes. Read the raw body on this route. * **The wrong secret.** Each endpoint has its own. Adding the same address again replaces its secret. * **The clock is off.** A server more than five minutes out fails the time check on every post.