> ## Documentation Index
> Fetch the complete documentation index at: https://docs.seosorted.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless API (Next.js & custom sites)

> Pull your published articles into Next.js, Astro, Remix, Nuxt — or anything that can make an HTTP request.

Every other connector **pushes** articles into a CMS. The Headless API works the other way round:
your site **pulls** published articles from SeoSorted with a read-only blog key, and renders them
with your own components, routes and styles.

Use it when your blog is code, not a CMS — a Next.js app, an Astro site, a custom React frontend,
a mobile app, or a Framer/Webflow page that fetches JSON.

<CardGroup cols={3}>
  <Card title="5-minute setup" icon="bolt">
    One environment variable and one fetch helper.
  </Card>

  <Card title="SEO built in" icon="magnifying-glass">
    Meta title, description, JSON-LD, canonical URL and a sitemap endpoint.
  </Card>

  <Card title="Your design" icon="paintbrush">
    Ready-rendered HTML *and* markdown — style it however you like.
  </Card>
</CardGroup>

## How it works

1. You connect the Headless API and get a **blog key** (`ss_blog_…`).
2. You publish an article to **Headless API (Next.js)** — manually, on a schedule, or with auto-publish.
3. We save a snapshot of the article. It is in the API **immediately**.
4. Your site fetches `GET /api/v1/content/articles` and `GET /api/v1/content/articles/{slug}` and renders them.

Editing an article in SeoSorted does **not** change the live post until you republish it — the same
rule as every other destination. Unpublishing or deleting the article removes it from the API.

## Step 1 — Get your blog key

**Project Settings → Integrations → Headless API (Next.js) → Connect**.

<ParamField path="Site URL" type="string">
  Optional. Where your blog is served. Defaults to your project's website. We use it, plus the
  content path from **Project Settings** (`/blog` by default), to build each article's live `url`.
</ParamField>

Click **Generate blog key**. The panel that opens shows the key, the API base URL, copy-paste
snippets, and a prompt for AI coding assistants. Open it again any time from the card's menu →
**View key & setup**.

<Info>
  The key is **read-only**: it can only list articles you have already published — content that is
  public on your site anyway. Still, keep it server-side and out of client bundles. **Regenerate key**
  invalidates the old one immediately.
</Info>

## Step 2 — Add the key to your site

```bash .env.local theme={null}
SEOSORTED_BLOG_KEY=ss_blog_xxxxxxxxxxxxxxxxxxxxxxxx
```

Add the same variable in your host's settings (Vercel → Project → Settings → Environment Variables,
Netlify, etc.).

## Step 3 — Fetch articles

<Tabs>
  <Tab title="Next.js (App Router)">
    ```ts lib/seosorted.ts theme={null}
    import "server-only";

    const BASE = "https://seosorted.ai/api/v1/content";

    export interface ArticleIndex {
      id: string;
      slug: string;
      title: string;
      headline: string;
      metaDescription: string;
      image: string | null;
      readingTime: number;
      category: { title: string; slug: string };
      tags: { title: string; slug: string }[];
      publishedAt: string;
      createdAt: string;
      updatedAt: string;
    }

    export interface Article extends ArticleIndex {
      metaTitle: string;
      metaKeywords: string;
      focusKeyword: string | null;
      imageAlt: string | null;
      author: string | null;
      wordCount: number | null;
      url: string | null;
      html: string;
      markdown: string;
      outline: string[];
      schemaJsonLd: string | null;
      relatedPosts: { id: string; headline: string; slug: string }[];
    }

    async function get<T>(path: string): Promise<T | null> {
      const res = await fetch(`${BASE}${path}`, {
        headers: { Authorization: `Bearer ${process.env.SEOSORTED_BLOG_KEY}` },
        next: { revalidate: 300 }, // re-check every 5 minutes
      });
      if (res.status === 404) return null;
      if (!res.ok) throw new Error(`SeoSorted API ${res.status}`);
      return (await res.json()).data as T;
    }

    type Page = { articles: ArticleIndex[]; total: number; page: number; limit: number };

    export const getArticles = (page = 0, limit = 10) =>
      get<Page>(`/articles?page=${page}&limit=${limit}`);
    export const getCategoryArticles = (slug: string, page = 0, limit = 10) =>
      get<Page>(`/articles?category=${encodeURIComponent(slug)}&page=${page}&limit=${limit}`);
    export const getTagArticles = (slug: string, page = 0, limit = 10) =>
      get<Page>(`/articles?tag=${encodeURIComponent(slug)}&page=${page}&limit=${limit}`);
    export const getArticle = (slug: string) =>
      get<Article>(`/articles/${encodeURIComponent(slug)}`);
    export const getSitemap = () =>
      get<{
        articles: { slug: string; lastmod: string; url: string | null }[];
        categories: { slug: string; lastmod: string }[];
        tags: { slug: string; lastmod: string }[];
      }>("/sitemap");
    ```

    ```tsx app/blog/page.tsx theme={null}
    import Link from "next/link";
    import { getArticles } from "@/lib/seosorted";

    export default async function Blog({
      searchParams,
    }: {
      searchParams: Promise<{ page?: string }>;
    }) {
      const page = Number((await searchParams).page ?? 0);
      const data = await getArticles(page, 12);
      if (!data) return null;

      return (
        <main className="mx-auto max-w-5xl px-4 py-12">
          <h1 className="text-3xl font-bold">Blog</h1>
          <div className="mt-8 grid gap-8 md:grid-cols-3">
            {data.articles.map((a) => (
              <Link key={a.id} href={`/blog/${a.slug}`}>
                {a.image && <img src={a.image} alt="" className="aspect-video rounded-lg object-cover" />}
                <h2 className="mt-3 font-semibold">{a.headline}</h2>
                <p className="text-sm text-gray-600">{a.metaDescription}</p>
              </Link>
            ))}
          </div>
          <nav className="mt-10 flex justify-between">
            {page > 0 && <Link href={`/blog?page=${page - 1}`}>← Newer</Link>}
            {(page + 1) * 12 < data.total && <Link href={`/blog?page=${page + 1}`}>Older →</Link>}
          </nav>
        </main>
      );
    }
    ```

    ```tsx app/blog/[slug]/page.tsx theme={null}
    import type { Metadata } from "next";
    import Link from "next/link";
    import { notFound } from "next/navigation";
    import { getArticle } from "@/lib/seosorted";

    type Props = { params: Promise<{ slug: string }> };

    export async function generateMetadata({ params }: Props): Promise<Metadata> {
      const post = await getArticle((await params).slug);
      if (!post) return {};
      return {
        title: post.metaTitle,
        description: post.metaDescription,
        alternates: { canonical: `/blog/${post.slug}` },
        openGraph: {
          type: "article",
          title: post.metaTitle,
          description: post.metaDescription,
          images: post.image ? [post.image] : [],
          publishedTime: post.publishedAt,
        },
      };
    }

    export default async function Article({ params }: Props) {
      const post = await getArticle((await params).slug);
      if (!post) notFound();

      return (
        <article className="prose mx-auto max-w-2xl px-4 py-12">
          {post.schemaJsonLd && (
            <script
              type="application/ld+json"
              dangerouslySetInnerHTML={{ __html: post.schemaJsonLd }}
            />
          )}
          <h1>{post.headline}</h1>
          <p className="text-sm text-gray-500">
            {new Date(post.publishedAt).toLocaleDateString()} · {post.readingTime} min read
          </p>
          {post.image && <img src={post.image} alt={post.imageAlt ?? ""} />}
          <div dangerouslySetInnerHTML={{ __html: post.html }} />
          {post.relatedPosts.length > 0 && (
            <>
              <h2>Related posts</h2>
              <ul>
                {post.relatedPosts.map((r) => (
                  <li key={r.id}>
                    <Link href={`/blog/${r.slug}`}>{r.headline}</Link>
                  </li>
                ))}
              </ul>
            </>
          )}
        </article>
      );
    }
    ```

    ```ts app/blog/sitemap.xml/route.ts theme={null}
    import { getSitemap } from "@/lib/seosorted";

    const SITE = "https://yoursite.com";

    export async function GET() {
      const data = await getSitemap();
      const urls = [
        ...(data?.articles ?? []).map((a) => [`/blog/${a.slug}`, a.lastmod]),
        ...(data?.categories ?? []).map((c) => [`/blog/category/${c.slug}`, c.lastmod]),
        ...(data?.tags ?? []).map((t) => [`/blog/tag/${t.slug}`, t.lastmod]),
      ];
      const body = `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${urls.map(([loc, mod]) => `<url><loc>${SITE}${loc}</loc><lastmod>${mod}</lastmod></url>`).join("\n")}
    </urlset>`;
      return new Response(body, { headers: { "Content-Type": "application/xml" } });
    }
    ```

    <Tip>
      The article `html` uses plain semantic tags (`h2`, `p`, `ul`, `table`, `blockquote`, `img`,
      YouTube `iframe`). Wrap it in Tailwind Typography's `prose` class and it looks right with no
      extra CSS.
    </Tip>
  </Tab>

  <Tab title="Astro">
    ```astro src/pages/blog/[slug].astro theme={null}
    ---
    const res = await fetch(
      `https://seosorted.ai/api/v1/content/articles/${Astro.params.slug}`,
      { headers: { Authorization: `Bearer ${import.meta.env.SEOSORTED_BLOG_KEY}` } },
    );
    if (res.status === 404) return Astro.redirect("/404");
    const { data: post } = await res.json();
    ---
    <html>
      <head>
        <title>{post.metaTitle}</title>
        <meta name="description" content={post.metaDescription} />
        {post.schemaJsonLd && <script type="application/ld+json" set:html={post.schemaJsonLd} />}
      </head>
      <body>
        <h1>{post.headline}</h1>
        <article set:html={post.html} />
      </body>
    </html>
    ```
  </Tab>

  <Tab title="Any language (cURL)">
    ```bash theme={null}
    curl "https://seosorted.ai/api/v1/content/articles?page=0&limit=10" \
      -H "Authorization: Bearer $SEOSORTED_BLOG_KEY"

    curl "https://seosorted.ai/api/v1/content/articles/how-to-choose-a-crm" \
      -H "Authorization: Bearer $SEOSORTED_BLOG_KEY"
    ```
  </Tab>
</Tabs>

## Let an AI assistant build it

The setup panel's **AI prompt** tab contains a complete brief — endpoints, types, and the pages to
build — with your API URL filled in. Paste it into Cursor, Claude Code, v0 or Lovable and it will
add a full blog (index, article, category and tag pages, sitemap) to your project.

## Step 4 — Publish

Open any finished article → **Publish** → **Headless API (Next.js)**. Or set it as the
destination for scheduled and auto-published articles. The article is in the API as soon as
the publish job finishes; with `revalidate: 300` your site shows it within five minutes.

<Tip>
  Want it instantly? Add an [on-demand revalidation](https://nextjs.org/docs/app/api-reference/functions/revalidatePath)
  route to your site and point a [Webhook connector](/connectors/webhook) at it — but note a
  workspace holds one connector per kind, so the webhook is an extra, not a replacement.
</Tip>

## API reference

Base URL: `https://seosorted.ai/api/v1/content`. Every response is wrapped as `{ "data": … }`;
errors are `{ "error": { "code", "message" } }`.

### Authentication

Send the key as a bearer token:

```http theme={null}
Authorization: Bearer ss_blog_…
```

`X-Api-Key: ss_blog_…` and `?key=ss_blog_…` also work, for tools that can't set an
`Authorization` header.

### Endpoints

| Method & path | Returns |
| - | - |
| `GET /articles?page=0&limit=10` | `{ articles: ArticleIndex[], total, page, limit }` — newest first. `page` is zero-based, `limit` max 100. |
| `GET /articles?category={slug}` | Same, filtered to one category. |
| `GET /articles?tag={slug}` | Same, filtered to one tag. |
| `GET /articles/{slug}` | `Article`, or `404`. |
| `GET /categories` | `[{ title, slug, count }]` |
| `GET /tags` | `[{ title, slug, count }]` |
| `GET /sitemap` | `{ articles: [{ slug, lastmod, url }], categories: [{ slug, lastmod }], tags: [{ slug, lastmod }] }` |

Interactive reference with every schema: [seosorted.ai/api/v1/reference](https://seosorted.ai/api/v1/reference#tag/content-api).

### Fields

<ResponseField name="ArticleIndex" type="object">
  <Expandable title="properties">
    <ResponseField name="id" type="string">Stable article id.</ResponseField>
    <ResponseField name="slug" type="string">URL slug.</ResponseField>
    <ResponseField name="title / headline" type="string">The article's H1 (both fields hold the same value).</ResponseField>
    <ResponseField name="metaDescription" type="string">SEO description, also a good excerpt.</ResponseField>
    <ResponseField name="image" type="string | null">Featured image URL.</ResponseField>
    <ResponseField name="readingTime" type="number">Minutes, at 200 words per minute.</ResponseField>
    <ResponseField name="category" type="{ title, slug }">From the content type: Blog, Guides, How-to, Lists, Comparisons.</ResponseField>
    <ResponseField name="tags" type="{ title, slug }[]">Focus keyword plus secondary keywords.</ResponseField>
    <ResponseField name="publishedAt / createdAt / updatedAt" type="ISO 8601">`updatedAt` moves on every republish — use it for `lastmod`.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="Article" type="ArticleIndex +">
  <Expandable title="properties">
    <ResponseField name="html" type="string">Rendered body, without the H1. Render with `dangerouslySetInnerHTML` / `set:html`.</ResponseField>
    <ResponseField name="markdown" type="string">The same body as markdown, if you render it yourself.</ResponseField>
    <ResponseField name="metaTitle" type="string">For `<title>`.</ResponseField>
    <ResponseField name="schemaJsonLd" type="string | null">Article (and FAQ) JSON-LD, ready for a `<script type="application/ld+json">`.</ResponseField>
    <ResponseField name="outline" type="string[]">Every heading, in order — handy for a table of contents.</ResponseField>
    <ResponseField name="relatedPosts" type="{ id, headline, slug }[]">Up to three, sharing a tag or the category.</ResponseField>
    <ResponseField name="url" type="string | null">The live URL we built from your Site URL and content path.</ResponseField>
    <ResponseField name="author, imageAlt, focusKeyword, metaKeywords, wordCount" type="…">Everything else, for bylines and meta tags.</ResponseField>
  </Expandable>
</ResponseField>

### Limits and caching

* **600 requests per minute per key.** Over that you get `429` with `retryAfterSeconds`.
* Responses carry `Cache-Control: private, max-age=60`. Cache on your side — Next.js `revalidate`,
  ISR or a CDN — rather than calling the API on every page view.
* CORS is open, so the API works from the browser too. Prefer server-side fetching so the key stays
  out of your bundle.

## Moving from SEObot

The response shapes match SEObot's `IArticle` / `IArticleIndex`, so your templates keep working.
Swap the client calls:

| SEObot `BlogClient` | SeoSorted |
| - | - |
| `getArticles(page, limit)` | `GET /articles?page=&limit=` |
| `getCategoryArticles(slug, page, limit)` | `GET /articles?category=&page=&limit=` |
| `getTagArticles(slug, page, limit)` | `GET /articles?tag=&page=&limit=` |
| `getArticle(slug)` | `GET /articles/{slug}` |
| `/api/sitemap?key=` | `GET /sitemap` |

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Invalid blog key">
    The key was regenerated, or copied with a missing character. Copy it again from **View key &
    setup** and redeploy so your host picks up the new environment variable.
  </Accordion>

  <Accordion title="The list is empty">
    Only articles **published to Headless API** appear — not drafts, and not articles published to
    another connector. Publish one and check again.
  </Accordion>

  <Accordion title="I edited an article but the site shows the old version">
    The API serves what you last published. Click **Republish** on the article. If it is
    republished, your site's cache (`revalidate`) may still be holding the old copy.
  </Accordion>

  <Accordion title="Images or links look unstyled">
    The HTML is unstyled on purpose. Add Tailwind's `prose` class or your own CSS for `article h2`,
    `article img`, `article table` and so on.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.