$ cli-blog docs
Examples & Guides

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 install

Faraday 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.com

Do 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
end

Define 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"))
end

Place 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
end

Use 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
end

Request 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]
  )
end

Use <%= 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
end

Do 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.

On this page