Add a blog to Ruby on Rails
Build a Rails blog with published posts, safe Markdown, authorized drafts, cursor pagination, caching, sitemap, and RSS.
Rails can treat Cli Blog as a content service while keeping routes, layouts, caching, authentication, and rendering inside your application.
What you will build
- A cached blog index with cursor navigation.
- A locale-aware article page with Markdown and SEO metadata.
- An authenticated controller action that creates drafts.
- Sitemap and RSS routes on your public domain.
Prerequisites
You need a current Rails application, Ruby 3.2 or newer, and a Cli Blog organization with public and private API keys.
Install dependencies
Add these gems to your Gemfile:
gem "faraday"
gem "faraday-retry"
gem "redcarpet"Then install them:
bundle installFaraday handles HTTP, and Redcarpet converts Markdown with raw HTML disabled.
Configure API keys
Store values in Rails credentials or your deployment environment:
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
CLI_BLOG_BASE_URL=https://api.cli-blog.comDo not place keys in config/application.js, import maps, view data, error reports, or committed files. The private key belongs only in trusted Rails code.
Create a service client
Create app/services/cli_blog_client.rb:
class CliBlogClient
def initialize(api_key:)
@api_key = api_key
@connection = Faraday.new(
url: ENV.fetch("CLI_BLOG_BASE_URL", "https://api.cli-blog.com")
) do |faraday|
faraday.request :json
faraday.request :retry, max: 2, interval: 0.25,
retry_statuses: [429, 502, 503, 504]
faraday.response :json, content_type: /json/
faraday.options.timeout = 10
end
end
def get(path, params = {})
response = @connection.get(path, params) { |request| headers(request) }
handle(response)
end
def post(path, body)
response = @connection.post(path, body) { |request| headers(request) }
handle(response)
end
private
def headers(request)
request.headers["x-api-key"] = @api_key
request.headers["accept"] = "application/json"
end
def handle(response)
return response.body if response.success?
raise CliBlogError.new(response.status, response.body)
end
endDefine CliBlogError as an application error that retains the status and a safe message. Do not attach request headers or environment values.
Create separate instances in the code that needs them:
def public_blog
@public_blog ||= CliBlogClient.new(api_key: ENV.fetch("CLI_BLOG_PUBLIC_KEY"))
end
def admin_blog
@admin_blog ||= CliBlogClient.new(api_key: ENV.fetch("CLI_BLOG_API_KEY"))
endPlace these private memoized methods on the controller or a shared service boundary. This prevents one request from rebuilding the HTTP connection for every call.
List published posts
class BlogController < ApplicationController
def index
after = params[:after].to_s
cache_key = ["blog", "index", "en-US", Digest::SHA256.hexdigest(after)]
@result = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
public_blog.get("/v1/posts", {
status: "published",
locale: "en-US",
fields: "summary,seo",
include: "authors,tags",
limit: 20,
after: after.presence
}.compact)
end
end
endUse summary and seo for list cards. Includes embed authors and tags in the same response.
Render the list and cursor
<% @result.fetch("data").each do |post| %>
<article>
<h2><%= link_to post.fetch("title"), blog_post_path(post.fetch("slug")) %></h2>
<p><%= post["excerpt"] %></p>
</article>
<% end %>
<% if @result["has_more"] && @result["next_cursor"].present? %>
<%= link_to "More posts", blog_index_path(after: @result["next_cursor"]) %>
<% end %>Rails escapes output by default. Treat the cursor as opaque and preserve the locale, filters, and sort between requests. The maximum limit is 100. Prefer cursor pagination; numbered pages do extra total-count work.
Retrieve a post by slug
def show
slug = params.require(:slug)
@post = Rails.cache.fetch(["blog", "post", "en-US", slug], expires_in: 5.minutes) do
public_blog.get("/v1/posts/#{ERB::Util.url_encode(slug)}", {
locale: "en-US",
fields: "summary,content,seo",
include: "authors,categories,tags,media"
})
end
rescue CliBlogError => error
raise ActiveRecord::RecordNotFound if error.status == 404
raise
endRequest content only on detail routes. Slug lookup is locale-aware.
Render Markdown safely
Create a helper that rejects embedded HTML before Rails sanitizes the result:
def safe_markdown(source)
renderer = Redcarpet::Render::HTML.new(filter_html: true, safe_links_only: true)
markdown = Redcarpet::Markdown.new(renderer, fenced_code_blocks: true, tables: true)
sanitize(
markdown.render(source),
tags: %w[p h2 h3 h4 ul ol li blockquote pre code a strong em table thead tbody tr th td],
attributes: %w[href title]
)
endUse <%= safe_markdown(@post.fetch("body_markdown")) %> in the article template. Keep ordinary ERB escaping for titles, descriptions, bylines, and alt text.
Add SEO metadata
<% content_for :title, @post["seo_title"].presence || @post.fetch("title") %>
<% content_for :meta_description, @post["seo_description"].presence || @post["excerpt"] %>
<% if @post["canonical_url"].present? %>
<link rel="canonical" href="<%= @post["canonical_url"] %>">
<% end %>Let Rails escape the canonical value. Use the rest of the seo field group for robots and social overrides, and included media for social images.
Create an authorized draft
Place this action behind your existing editor authentication and authorization:
def create
input = params.require(:post).permit(:title, :body_markdown)
return render(json: { error: "title and body are required" }, status: :unprocessable_entity) if
input[:title].blank? || input[:body_markdown].blank?
draft = admin_blog.post("/v1/posts", {
title: input[:title],
body_markdown: input[:body_markdown],
locale: "en-US",
status: "draft"
})
render json: { id: draft.fetch("id"), status: draft.fetch("status") }, status: :created
endDo not permit status from the request. Make publishing a separate action that requires review and optimistic conflict handling.
Serve sitemap and feed XML
Add controller actions that call GET https://api.cli-blog.com/v1/sitemap?locale=en-US&limit=100 and GET https://api.cli-blog.com/v1/feed?locale=en-US&limit=20 with the public key. Render the raw bodies with application/xml and application/rss+xml, cache them, and route /sitemap.xml and /feed.xml to those actions.
Cache and revalidate
Include locale, slug, fields, includes, filters, and cursor in cache keys. After an approved publish, expire the index, changed slug, sitemap, and feed. Never put drafts or authenticated preview output in a shared cache.
Handle errors
Map 404 to your not-found page. A 401 means the key is missing or invalid; 403 means the key type or permissions are wrong; 409 signals a write conflict; 429 should be retried later. Bound retries to transient methods and statuses, and do not log request headers.
Production checklist
- Keep private keys in encrypted credentials or deployment secrets.
- Protect write actions with authentication, authorization, CSRF protection, strong parameters, and rate limits.
- Cache published content and expire dependent pages after publishing.
- Request lean field groups and preserve cursor filters.
- Reject raw HTML in Markdown and sanitize the rendered allowlist.
- Test empty, not-found, timeout, rate-limit, sitemap, feed, and cache invalidation states.