Serve an /llms.txt file per the llmstxt.org spec.
The llms.txt file provides LLM-friendly content about your site: a title, summary, and categorized links to key pages — structured progressively so LLMs can stop reading early and still have useful context.
Quick start
Add
"md"to your router pipeline and forward/llms.txt:pipeline :browser do plug :accepts, ["html", "md"] end forward "/llms.txt", SEO.LLMs, config: MyAppWeb.SEO, provider: MyAppWeb.LLMsProviderCreate markdown view modules (
FooMD) that implement this behaviour:defmodule MyAppWeb.ArticleMD do @behaviour SEO.LLMs use MyAppWeb, :verified_routes def show(%{article: article}) do """ # #{article.title} #{article.body} """ end @impl SEO.LLMs def entry(article) do SEO.LLMs.Entry.build( section: "Articles", title: article.title, url: ~p"/articles/#{article.slug}", description: article.summary ) end endRegister the markdown view in your controllers:
defmodule MyAppWeb.ArticleController do use MyAppWeb, :controller plug :put_view, html: MyAppWeb.ArticleHTML, md: MyAppWeb.ArticleMD def show(conn, %{"slug" => slug}) do article = Blog.get_article_by_slug!(slug) render(conn, :show, article: article) # html → ArticleHTML.show/1 # md → ArticleMD.show/1 end endCreate a provider that assembles the llms.txt index:
defmodule MyAppWeb.LLMsProvider do @behaviour SEO.LLMs.Provider use MyAppWeb, :verified_routes alias SEO.LLMs.Entry @impl true def sections do articles = MyApp.Blog.list_published() entries = Enum.map(articles, &MyAppWeb.ArticleMD.entry/1) dynamic = Entry.group_by_section(entries) static = [ {"Docs", [ {"About", ~p"/about", "What this site covers"} ]} ] static ++ dynamic end end
How it works
Phoenix resolves view modules by format: ArticleHTML for HTML, ArticleJSON
for JSON, ArticleMD for markdown. A single render(conn, :show, article: article)
call dispatches to the right view based on content negotiation.
Your FooMD modules serve double duty:
- Phoenix view functions (
show/1,index/1, etc.) render full markdown content when the"md"format is requested - The
entry/1callback provides metadata for the llms.txt index — section, title, URL, and description
The provider collects entries from your MD modules and groups them into sections. The Plug renders the final llms.txt file, pulling the site title and description from your existing SEO config.
Using MDEx with Phoenix
MDEx is a fast markdown library for Elixir that
pairs naturally with FooMD view modules. It provides a ~MD sigil for markdown
templates — the markdown equivalent of HEEx's ~H sigil for HTML.
| Format | View module | Template engine | Sigil |
|---|---|---|---|
| HTML | FooHTML | HEEx | ~H |
| JSON | FooJSON | Plain maps | — |
| Markdown | FooMD | MDEx | ~MD |
Setup
Add MDEx to your dependencies:
{:mdex, "~> 0.12"}The ~MD sigil
The ~MD sigil with the MD modifier outputs CommonMark markdown (not HTML).
It supports assigns ({@var}) and expressions (<%= ... %>), and is processed
at compile time for performance:
defmodule MyAppWeb.ArticleMD do
@behaviour SEO.LLMs
use MyAppWeb, :verified_routes
import MDEx.Sigil
def show(assigns) do
~MD"""
# {@article.title}
> Published {@article.date}
{@article.body}
## Related
<%= for tag <- @article.tags do %>
- #{tag}
<% end %>
"""MD
end
@impl SEO.LLMs
def entry(article) do
SEO.LLMs.Entry.build(
section: "Articles",
title: article.title,
url: ~p"/articles/#{article.slug}",
description: article.summary
)
end
endSee MDEx.Sigil for the full list of modifiers and options.
When to use the sigil vs string interpolation
The ~MD sigil processes templates at compile time, which makes it ideal for
views with structured, known layouts — like documentation pages or about pages.
For DB-backed content where the body is already markdown (like a blog post stored
as markdown), plain string interpolation is simpler:
# Compile-time template — good for structured pages
def show(assigns) do
~MD"""
# {@page.title}
{@page.body}
"""MD
end
# Runtime interpolation — good for DB-backed markdown content
def show(%{article: article}) do
"""
# #{article.title}
#{article.body}
"""
endConverting HTML content to markdown
If your content is stored as HTML and you need to serve it as markdown, MDEx can parse and re-render it:
def show(%{article: article}) do
article.html_body
|> MDEx.parse_document!()
|> MDEx.to_markdown!()
endPlug options
:title— H1 heading. Falls back toopen_graph.site_namefrom config.:description— Blockquote summary. Falls back tosite.descriptionfrom config.:body— Optional prose between the summary and sections.:sections— Static list of{section_name, entries}tuples.:provider— Module implementingSEO.LLMs.Providerfor dynamic sections.:config— Youruse SEOmodule or config map, used to derive title/description.
Static sections (without a provider)
For simple sites you can skip the provider and declare sections inline:
forward "/llms.txt", SEO.LLMs,
config: MyAppWeb.SEO,
sections: [
{"Docs", [
{"API Reference", "/docs/api", "Full REST API docs"},
{"Guides", "/docs/guides"}
]},
{"Optional", [
{"Changelog", "/changelog"}
]}
]
Summary
Callbacks
Callback for markdown view modules (FooMD) to provide llms.txt entries.
Functions
Render the llms.txt markdown string from a map of options.
Callbacks
@callback entry(term()) :: SEO.LLMs.Entry.t() | [SEO.LLMs.Entry.t()] | nil
Callback for markdown view modules (FooMD) to provide llms.txt entries.
Receives a resource and returns an SEO.LLMs.Entry, a list of entries, or nil.
Used by SEO.LLMs.Provider implementations to build the llms.txt index from
your view modules.
Functions
Render the llms.txt markdown string from a map of options.
Expects a map with:
:title(required) — the H1 heading:description(optional) — the blockquote summary:body(optional) — prose content between summary and sections:sections— list of{name, entries}tuples