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 posts table, 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
    bundle add reverse_markdown
    rails/rails#56858 (to_markdown on 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.format in the controller returns the negotiated format — useful for conditional logic elsewhere.
  • For JSON-API-style apps, prefer format.json over Markdown; this site is about content resources (articles, docs), not API objects.