Serve markdown to AI agents on Hugo, with the Hugo logo — AIScan cover
Serve markdown to AI agents on Hugo, with the Hugo logo — AIScan cover
AI Readiness

How to serve text/markdown to AI agents on Hugo

Make Hugo return Markdown to AI agents that send Accept: text/markdown with one output format, one template and one nginx rule, tested on Hugo v0.167.0.

AAIScan Content Team 3 Oct 2026 6 min read
#Hugo#Markdown#content negotiation#nginx#AI agents

This guide covers C1 · Content.

Table of contents

An AI agent that sends Accept: text/markdown to a Hugo page gets HTML back, because Hugo builds files and a file cannot read a request header. AIScan check C1 grades exactly that request. This guide makes Hugo write a Markdown twin of every page at build time, then uses one nginx rule to hand the twin to agents at the same URL. Everything below was built and run on Hugo v0.167.0 and nginx 1.24.0 on 3 October 2026. Browsers keep getting HTML.

Quick summary

If you want to...Do thisTime
Write a Markdown file next to every HTML pageAdd the built-in markdown output format to [outputs]2 min
Make that file readable by agentsOne layouts/page.md template using transform.HTMLToMarkdown5 min
Serve it at the original URLAn nginx map on the Accept header plus try_files10 min
Confirm it worksnpx aiscan-cli yoursite.com, then read the C1 row1 min

Why Hugo needs two halves

Check C1 re-requests a page with Accept: text/markdown and passes when the server answers text/markdown. That is proactive negotiation as defined in RFC 9110: one URL, several representations, chosen by a request header. A /page.md sidecar alone does not pass, because the scanner asks the original URL.

Hugo can do the first half and cannot do the second. The output formats documentation lists markdown among the built-in formats, with media type text/markdown and the base name index. Hugo has no request-time code, so the choice between the two files belongs to whatever serves them. The dev server confirmed it: hugo server answered Accept: text/markdown with text/html; charset=utf-8.

Step 1: turn on the Markdown output format

Open hugo.toml and list markdown next to html for the page kinds you want agents to read. The home page and section lists need their own entries, otherwise they have no Markdown twin.

[outputs]
  home    = ['html', 'markdown']
  section = ['html', 'markdown']
  page    = ['html', 'markdown']

Hugo now writes public/posts/hello/index.md beside public/posts/hello/index.html. Change nothing else yet; the file will be empty until a template exists.

Step 2: add the Markdown templates

Hugo 0.146 overhauled template lookup, and the lookup order page now names templates by kind, so a single page is page.md, the home page is home.md and a section is section.md. Create layouts/page.md:

# {{ .Title }}

{{ .Content | transform.HTMLToMarkdown }}

Then create layouts/home.md and copy it to layouts/section.md:

# {{ .Site.Title }}

{{ range site.RegularPages }}- [{{ .Title }}]({{ .Permalink }})
{{ end }}

I used transform.HTMLToMarkdown instead of .RawContent on purpose. .RawContent prints the source file, so a page containing {{< figure src="/a.png" caption="Cap" >}} came out with the shortcode text still in it. Converting the rendered .Content produced ![Cap](/a.png) and a caption line instead, and tables survived as pipe tables. The function's documentation marks it as new in v0.151.0 and experimental, so pin your Hugo version and re-run the check after upgrades.

Step 3: advertise the twin in the HTML head

Agents that read the page first can find the twin from a link element. Add this inside <head> in layouts/baseof.html, or in the partial your theme uses for the head:

{{ range .AlternativeOutputFormats -}}
  <link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink }}">
{{ end -}}

The built page carried <link rel="alternate" type="text/markdown" href="https://example.com/posts/hello/index.md">. This is a hint and does not replace Step 4.

Step 4: route by Accept header in nginx

Hugo's output is a folder of files, so the rule is "serve index.md instead of index.html when the client prefers Markdown". In the http block of /etc/nginx/nginx.conf or a file it includes, add:

types { text/markdown md; }

map $http_accept $index_file {
    default index.html;
    "~*^text/markdown(?!\s*;\s*q=0(\.0+)?(,|$))" index.md;
}

Then, in your server block, point root at the Hugo public folder and use try_files:

add_header Vary Accept always;

location / {
    try_files $uri $uri$index_file $uri/$index_file =404;
}

Reload with nginx -t && nginx -s reload. The pattern matches only when the header begins with text/markdown and that type is not set to q=0. A browser header that starts with text/html never matches. Vary: Accept tells caches that the same URL has two answers.

If you would rather use Caddy, Apache, or a CDN rule, the logic is the same, but I tested only nginx.

Step 5: send one URL several Accept values

Request the same page with different headers and read the status and content type. These are the results from the test site:

Accept headerContent-Type returned
nonetext/html
text/markdowntext/markdown
text/markdown, text/html;q=0.8text/markdown
text/html, text/markdown;q=0.5text/html
*/*text/html
text/markdown;q=0text/html
text/markdown;q=0, text/htmltext/html

All of them returned 200, including /posts/hello without the trailing slash, and every response carried Vary: Accept. The home page also answered Markdown once home was listed in [outputs]. Before I added it, Accept: text/markdown on / returned 404, because index.md did not exist at the root.

Two traps I hit

The first is the MIME type. Without the types { text/markdown md; } line, nginx served index.md as text/plain, and C1 wants text/markdown. The stock mime.types file on Ubuntu has no entry for md.

The second is the host. A Hugo site on GitHub Pages, or any host that only serves files by path, cannot read the header, so Steps 1 to 3 still help (the .md files exist and are linked) but C1 will not pass. I did not test Netlify or Cloudflare Pages here; check that host's documentation for header-based rewrites, then confirm with the commands below.

Check it: scan first, curl second

Run the scanner against the live site and read the C1 row:

npx aiscan-cli yoursite.com

By hand, request one URL twice and compare:

curl -sI -H "Accept: text/markdown" https://yoursite.com/posts/hello/
curl -sI -H "Accept: text/html" https://yoursite.com/posts/hello/

A pass is content-type: text/markdown on the first, content-type: text/html on the second, and vary: accept on both. If the first still says HTML, the server is not reading the header; start at Step 4.

What this does not cover

The scan makes one request from one place. It cannot see a CDN in front of nginx caching the wrong variant, so purge the cache after the change and confirm Vary: Accept survives it. It also cannot judge the Markdown itself. Open one converted page and read it as an agent would, especially pages with shortcodes, because the converter is experimental. Re-run the scan after each Hugo upgrade.

Next step

Run npx aiscan-cli yoursite.com and read C1. The same check on other stacks is in serving text/markdown on Astro and on Next.js. Hugo's other readiness files are in publishing llms.txt on Hugo and the full Hugo setup guide. The check page is /docs/checks/content and every other fix is in /guides.

Frequently asked questions

How do I make Hugo return Markdown when an agent sends Accept: text/markdown?

Add the built-in markdown output format to [outputs], add a layouts/page.md template, and put a header-based rule in front of the generated files (nginx on the test site). Hugo v0.167.0 wrote index.md next to index.html, and nginx 1.24.0 returned text/markdown to one request and text/html to the other at the same URL.

Can Hugo do the content negotiation by itself?

No. Hugo builds files and has no request-time code. hugo server answered Accept: text/markdown with text/html in testing. Hugo can produce the Markdown twin of every page, but the web server or CDN has to choose which file to send.

Why does my index.md file come out empty?

A markdown output format needs a matching template. With no layouts/page.md (or home.md for the home page, section.md for sections), Hugo has nothing to render. Create the template and rebuild.

Why does nginx serve my Markdown as text/plain?

The stock Ubuntu mime.types file has no entry for md, so nginx falls back to text/plain and check C1 expects text/markdown. Add types { text/markdown md; } to the http block and reload.

Why does Accept: text/markdown on my home page return 404?

Only page kinds listed in [outputs] get a Markdown file. If home is missing, public/index.md does not exist and the try_files rule ends in =404. Add home = ['html', 'markdown'] and a layouts/home.md template.

Why do my shortcodes show up as raw text in the Markdown?

The template printed .RawContent, which is the source file. Print .Content | transform.HTMLToMarkdown instead. A figure shortcode then became an image line and a caption, and tables stayed pipe tables.

Will browsers start receiving Markdown?

Not with the pattern shown. It matches only when the Accept header begins with text/markdown and that type is not q=0. A browser header starting with text/html, and /, both returned text/html in testing.

Does this work on GitHub Pages or other file-only hosts?

The Markdown files get built and linked, but a host that serves files strictly by path cannot read the Accept header, so C1 will not pass there. I did not test Netlify or Cloudflare Pages; check the host's documentation for header-based rewrites and confirm with the curl commands above.

Related guides