class PostsController < ApplicationController
def new
@post = Post.new
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post
else
render :new, status: :unprocessable_entity
end
end
def preview
@rendered = MarkdownRenderer.render(params[:content].to_s)
respond_to do |format|
format.turbo_stream
format.html { render partial: "posts/preview", locals: { rendered: @rendered } }
end
end
private
def post_params
params.require(:post).permit(:title, :content)
end
end
class MarkdownRenderer
ALLOWED_TAGS = %w[h1 h2 h3 p br strong em ul ol li a code pre blockquote hr].freeze
ALLOWED_ATTRS = %w[href].freeze
def self.render(text)
new.render(text)
end
def render(text)
return "".html_safe if text.blank?
html = markdown.render(text)
helpers.sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRS)
end
private
def markdown
@markdown ||= Redcarpet::Markdown.new(
Redcarpet::Render::HTML.new(hard_wrap: true, filter_html: false),
fenced_code_blocks: true,
autolink: true,
strikethrough: true,
no_intra_emphasis: true
)
end
def helpers
ActionController::Base.helpers
end
end
<%= form_with model: @post, url: preview_posts_path, method: :post,
data: { controller: "preview", turbo_frame: "markdown_preview" } do |f| %>
<div class="editor">
<%= f.label :content, "Markdown" %>
<%= f.text_area :content,
name: "content",
rows: 16,
data: {
preview_target: "input",
action: "input->preview#schedule"
} %>
</div>
<% end %>
<section class="preview">
<h2>Preview</h2>
<%= turbo_frame_tag "markdown_preview" do %>
<p class="muted">Start typing to see a live preview…</p>
<% end %>
</section>
<%= turbo_stream.update "markdown_preview" do %>
<div class="rendered-markdown">
<% if @rendered.blank? %>
<p class="muted">Nothing to preview yet.</p>
<% else %>
<%= @rendered %>
<% end %>
</div>
<% end %>
This snippet shows how a live markdown preview is built with Turbo Frames instead of a client-side markdown parser, keeping the rendering logic on the server where it can be sanitized consistently. The idea is that the textarea's contents are periodically POSTed to a preview action, which renders the compiled HTML back into a lazy-loading Turbo Frame. Because the frame swaps its own contents on every matching response, the editor updates in place without a full page reload and without duplicating markdown rules in JavaScript.
In PostsController, the preview action never touches the database: it reads params[:content], runs it through MarkdownRenderer.render, and responds with a small partial. The response is wrapped in a turbo_frame_tag with the id markdown_preview, so Turbo matches it to the frame already on the page and replaces only that fragment. Rendering server-side lets a single trusted pipeline handle both preview and the eventual saved post, avoiding drift between what an author sees and what readers get.
MarkdownRenderer wraps Redcarpet with the html_safe-aware pattern that matters most: it renders markdown to HTML and then passes the result through Rails' sanitize helper via an ActionController::Base.helpers proxy. This is the key trade-off — markdown can emit arbitrary HTML, so the output is treated as untrusted and stripped to an allowlist of tags and attributes before being marked safe. fenced_code_blocks and autolink are enabled for a realistic authoring experience.
The _form.html.erb tab ties it together. The form_with targets the preview action, data-turbo-frame points responses at the frame, and a small Stimulus controller reference (data-controller="preview") debounces input so a request fires roughly every 400ms rather than on every keystroke. The turbo_frame_tag "markdown_preview" holds the last rendered fragment.
A pitfall worth noting: without debouncing, fast typing floods the server with requests, and out-of-order responses can flicker the preview. Turbo requests to the same frame are also naturally superseded, which mitigates stale renders. This pattern suits any editor where correctness and shared rendering rules outweigh the latency of a network round-trip.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.