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

# Markdown Twins

> Publish a clean Markdown copy of every page so AI assistants read your content instead of parsing your theme.

When ChatGPT, Claude or Perplexity fetches one of your pages, it does not see the page the way a visitor does. It receives the raw HTML: your navigation, cookie banner, scripts, tracking pixels, footer, and somewhere inside all of that, the words you actually wrote.

A **Markdown twin** is a second copy of the same page, published at its own address, containing only the content. Same words, no layout.

<Tip>
  On a typical article the twin is 15 to 25% the size of the HTML page. That difference is the assistant's reading budget, and it is spent on your content rather than on your markup.
</Tip>

***

## Why this matters

An assistant answering a question has a fixed amount it can read before it stops. Every kilobyte of navigation markup is a kilobyte it does not spend on your argument, your data, or your product.

Serving a twin does three things:

* **Your content is read in full**, rather than truncated halfway through the page
* **Quotes come out clean**, because there is no markup to strip and misread
* **Nothing is guessed**, since headings, lists and tables arrive already structured

Your normal pages are unchanged. Visitors, Google and everything else keep seeing exactly what they saw before.

***

## Setting it up on WordPress

The SnowSEO plugin publishes twins for every public post and page. There is nothing to write.

<Steps>
  <Step title="Install and connect the plugin">
    Install **SnowSEO** from the WordPress plugin directory and connect it to your brand under **SnowSEO → Settings**.

    Already publishing to this site from SnowSEO? Then this is done.
  </Step>

  <Step title="Switch twins on">
    Go to **Settings → Reading** in the WordPress admin and tick **"Publish a markdown copy of every public post and page"**, then save.

    <Note>
      This is off until you turn it on. Enabling it publishes new addresses on your site, so the plugin does not do it for you.
    </Note>
  </Step>

  <Step title="Check it worked">
    Open any published post and add `.md` to the end of its address:

    ```
    https://example.com/your-post.md
    ```

    You should see plain text, starting with a short block of title and date information, then the article. If you get your normal page or a 404, see [Troubleshooting](#troubleshooting) below.
  </Step>
</Steps>

### What gets a twin

Every post and page that is:

* Published, and not password protected
* Publicly viewable
* Not marked **noindex** by Yoast, Rank Math, SEOPress, The SEO Framework or All in One SEO

That last rule matters. If you have told search engines to ignore a page, SnowSEO does not publish a Markdown copy of it either.

Drafts, private posts, and anything behind a password are never published as a twin.

***

## Setting it up on other platforms

For Next.js, Astro, Nuxt, SvelteKit, Express, Cloudflare Workers or a custom stack, the SnowSEO SDK does the same job.

<Steps>
  <Step title="Install">
    ```bash theme={null}
    npm install @snowseo/beacon
    ```
  </Step>

  <Step title="Point it at your content">
    If your pages come from Markdown files already, point at the folder:

    ```ts beacon.ts theme={null}
    import { createBeacon } from "@snowseo/beacon";

    export const beacon = createBeacon({
      siteUrl: "https://example.com",
      dir: "./content",
    });
    ```

    If they don't - a React or Vue app, a CMS, anything rendered rather than stored as Markdown - convert the rendered page instead:

    ```ts beacon.ts theme={null}
    import { createBeacon, createFetchResolver } from "@snowseo/beacon";

    export const beacon = createBeacon({
      siteUrl: "https://example.com",
      strictNegotiation: false,
      resolve: createFetchResolver({ siteUrl: "https://example.com" }),
    });
    ```

    <Warning>
      **`strictNegotiation: false` matters on Next.js and other React frameworks.**

      Left on, the SDK answers `406 Not Acceptable` when a request's `Accept` header names neither HTML nor Markdown. Next sends `Accept: text/x-component` for every client-side navigation, so strict mode breaks routing across your whole site.
    </Warning>

    <Note>
      No account or key is needed for Markdown twins. `siteUrl` is used to build the links between a page and its twin, so it must match the address your site is actually served on.
    </Note>
  </Step>

  <Step title="Wire it into your routing">
    ```ts proxy.ts theme={null}
    // middleware.ts before Next 16
    import { beaconMiddleware, beaconAdvertise } from "@snowseo/beacon/next";
    import { isTwinFetch } from "@snowseo/beacon";
    import { NextResponse } from "next/server";
    import { beacon } from "./beacon";

    export default async function proxy(request, event) {
      // Must be the first line. See the warning below.
      if (isTwinFetch(request)) return NextResponse.next();

      const markdown = await beaconMiddleware(beacon, request, event);
      if (markdown) return markdown;

      return beaconAdvertise(beacon, request, NextResponse.next(), event);
    }
    ```

    `beaconMiddleware` serves the twin when one is asked for. `beaconAdvertise` adds the header that tells an assistant the twin exists.

    <Warning>
      **The `isTwinFetch` guard is required when using `createFetchResolver`.**

      That resolver builds the twin by fetching your own page, so the request re-enters your middleware. Without the guard it calls itself until the request dies. It is not needed with `dir`, which reads from disk.
    </Warning>
  </Step>

  <Step title="Decide which paths get twins">
    Twins make sense for public pages and not for your app. Skip anything behind auth - a twin of a signed-out dashboard shell tells an assistant nothing and advertises a URL that never has content.

    ```ts theme={null}
    const NO_TWIN = ["/api", "/dashboard", "/admin", "/login", "/settings"];
    const twinnable = (p: string) =>
      !NO_TWIN.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
    ```

    If your site has a staging password or similar gate, run that gate first: a twin serves the same content as the page, so handling it earlier would let anyone read protected content by adding `.md`.
  </Step>
</Steps>

***

## How assistants find your twins

Three routes, so an assistant does not have to know your URL convention in advance.

| Route                        | What it looks like                                                                           |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| **The `.md` address**        | `https://example.com/your-post.md`                                                           |
| **Asking for Markdown**      | A request to your normal page with `Accept: text/markdown` returns the twin instead of HTML  |
| **The pointer on your page** | Every HTML page gains a `Link: rel="alternate"; type="text/markdown"` header naming its twin |

You also get **`/llms.txt`**, a single index of every twin on the site, on both WordPress and the SDK. That is the emerging convention for telling AI tools which pages matter, and it is pointed to from your `robots.txt` so it can be found without guessing.

***

## Twins and Google

Markdown twins are deliberately kept **out of search results**.

Each twin is served with `X-Robots-Tag: noindex, follow`. Without it, a search engine could index the raw Markdown file and show it in results, and a visitor clicking through would land on a page of unstyled text instead of your real page.

<Warning>
  This does not hide twins from AI assistants. An assistant that follows the pointer on your page, or asks for Markdown directly, reads the twin normally. `noindex` governs search results only.
</Warning>

For the same reason, twins are not listed in the sitemap your site submits to search engines. Submitting pages you have marked `noindex` produces "Submitted URL marked noindex" errors in Google Search Console, once per page.

<Note>
  The SDK behaves the same way, so twins are held out of search whichever platform you are on.

  If you would rather have them indexed, set `X-Robots-Tag` yourself and pair it with a `Link: rel="canonical"` back to the HTML page, so the two do not compete. Send one or the other, never both: a canonical asks for the twin to be consolidated onto your page, while `noindex` asks for it to be dropped, and the two directives contradict.
</Note>

***

## Checking your setup

Run a **GEO Audit** on your site and open the **Machine Readable** section. SnowSEO checks the twin end to end:

| Check                         | What it means                                                                                      |
| ----------------------------- | -------------------------------------------------------------------------------------------------- |
| **Markdown Twin**             | A twin is reachable and returns real content                                                       |
| **Twin Content-Type**         | It is served as `text/markdown`, not as a file to download                                         |
| **Twin Vary Header**          | Caches keep the HTML and Markdown versions apart                                                   |
| **Twin Canonical Link**       | The twin either points back at the page it copies, or is held out of search so it does not need to |
| **Twin nosniff**              | Clients cannot mistake the twin for another file type                                              |
| **Twin Token Count**          | The twin reports its size, so an assistant can budget for it                                       |
| **Twin Link from HTML**       | Your pages advertise their twins                                                                   |
| **HTML Vary Header**          | Your normal pages also tell caches the response varies by request type                             |
| **Markdown Negotiation**      | Asking for Markdown on a normal URL works                                                          |
| **406 on Unsupported Accept** | Asking for a format you do not serve gets a clear refusal                                          |
| **llms.txt**                  | Your index of twins is present                                                                     |
| **llms.txt Structure**        | That index follows the expected format                                                             |

You can also check by hand from a terminal:

```bash theme={null}
curl -I https://example.com/your-post.md
```

A working twin returns `200` with `Content-Type: text/markdown; charset=utf-8`.

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="The .md address returns a 404">
    Twins are off, or that particular page is not eligible.

    Check **Settings → Reading** first. If twins are on, confirm the post is published rather than a draft, is not password protected, and has not been marked **noindex** by your SEO plugin. A page marked noindex never gets a twin.
  </Accordion>

  <Accordion title="The .md address returns my normal page">
    Something in front of WordPress is serving a cached HTML copy.

    Most page caches store one response per address and do not know that `Accept: text/markdown` should produce a different one. SnowSEO marks Markdown responses as uncacheable, but a CDN configured to ignore those instructions can still override it. Clear the cache and try again; if it persists, exclude `.md` addresses from your cache rules.
  </Accordion>

  <Accordion title="Changes to a post are not showing in its twin">
    Twins are generated from the current content of the post, so a stale twin is almost always a cache.

    Clear your page cache, then request the `.md` address again. If your CDN caches aggressively, purge that address specifically.
  </Accordion>

  <Accordion title="I want to exclude one page">
    Mark it **noindex** in your SEO plugin. SnowSEO skips it, and the page stays out of both search results and Markdown twins.
  </Accordion>
</AccordionGroup>
