Rails
Rails ships a text/markdown MIME type, a markdown: renderer, and auto Vary: Accept. Define to_markdown on your model and respond_to the :md format.
Requires Rails 8+. The text/markdown MIME type and the
markdown: renderer described below shipped in Rails 8. On Rails 7
and earlier you’d need to register the MIME type yourself
(Mime::Type.register 'text/markdown', :md) and render manually with
render plain: @post.to_markdown, content_type: 'text/markdown; charset=utf-8'.
Rails has real content negotiation baked in. The text/markdown MIME
type is registered by default, there’s a dedicated markdown:
renderer that calls to_markdown on the object you pass it, and
Vary: Accept is set automatically when you negotiate via respond_to
— no MIME initializer, no manual render, no after_action for Vary.
1. Define to_markdown on the model
class Post < ApplicationRecord
def to_markdown
# Whatever returns the Markdown representation — a stored column,
# a computed conversion, or a cached field.
body_markdown
end
end
2. Use respond_to in the controller
class PostsController < ApplicationController
def show
@post = Post.find_by!(slug: params[:slug])
respond_to do |format|
format.html
format.md { render markdown: @post }
end
end
end
render markdown: @post calls @post.to_markdown and sends the result
with Content-Type: text/markdown; charset=utf-8. Rails parses
Accept and returns 406 Not Acceptable if the client asked for
something you didn’t register.
Gotcha: */* ties favor HTML
Rails’ tie-breaking on equal-q formats falls back to registration order, and HTML is registered before Markdown. That means a common real-world header like:
Accept: text/markdown, text/html, */*
(Claude Code sends this) still resolves to HTML on current Rails,
because */* pulls HTML up to a q=1 tie with text/markdown and HTML
wins the tie. Force the issue with a before_action:
class ApplicationController < ActionController::Base
before_action :prioritize_markdown_format
private
def prioritize_markdown_format
return unless request.accepts.first&.to_s == 'text/markdown'
request.formats = [:md, :html]
end
end
If the client’s top preference is text/markdown, pin :md to the
front of the format list for this request. Leaves other agents
unaffected.
This only fires when text/markdown is the first entry in Accept
with no explicit q-value. Headers like
text/markdown;q=0.9, text/html;q=0.8 (Markdown preferred via
q-value rather than order) won’t trigger it. For full q-value-aware
negotiation, replace the before_action with a custom
ActionController::MimeResponds extension — the header shape Claude
Code and other well-behaved agents send is covered by the version
above.
3. Vary: Accept is automatic
Rails sets Vary: Accept for you on negotiated responses. If you
already set a Vary header elsewhere (e.g. Accept-Encoding), Rails
won’t overwrite it — you’ll need to merge the value yourself in that
case:
after_action :ensure_vary_accept
def ensure_vary_accept
existing = response.headers['Vary']
return if existing.to_s.downcase.split(',').map(&:strip).include?('accept')
response.headers['Vary'] = existing ? "#{existing}, Accept" : 'Accept'
end
Otherwise, do nothing.
Where the Markdown comes from
The value to_markdown returns can be:
- A dedicated column on the
poststable, maintained by the author. - Action Text content — for now, convert the rendered HTML via
reverse_markdown:def to_markdown ReverseMarkdown.convert(body.to_s, github_flavored: true) end
rails/rails#56858 (bundle add reverse_markdownto_markdownon Action Text rich text) and rails/rails#56896 (Trix-specific handling) are merged on main. Once they land in a tagged Rails release, you can drop the gem and delegate:def to_markdown; body.to_markdown; end. - A cached representation regenerated on
after_save— worth the DB bytes if rendering is hot.
Verify
curl -sI -H "Accept: text/markdown" https://yoursite.com/posts/hello
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
curl -sI -H "Accept: application/x-does-not-exist" \
https://yoursite.com/posts/hello
# HTTP/1.1 406 Not Acceptable
Notes
- Action Pack’s format negotiation respects q-values by default. You don’t need to write a parser.
request.formatin the controller returns the negotiated format — useful for conditional logic elsewhere.- For JSON-API-style apps, prefer
format.jsonover Markdown; this site is about content resources (articles, docs), not API objects.