How We Built Unique Social Preview Images for Pika

One of the goals of Pika, the happy blogging software that we recently launched, is to help you find your own place on the internet. Along with a nice place on the internet, when you share links to your blog we want them to represent your internet home, which you've likely taken a little time to make just so. Sharing your blog on social networks, text messages, Slack, or wherever should be an experience that makes you smile, and that's why we built custom social preview images for your blog.

But how did we do it?

I spent quite a bit of time searching the internet for services that could help us make this happen. There were a few that seemed to have the general idea of what we wanted, but their APIs were challenging to understand, hidden behind a "talk to us" wall, or clearly not designed for this programmatic image generation use case we were trying to accomplish.

Looking at how others have done this was also on the table. Buttondown specifically has some nice social preview images, and thankfully they have blogged about it. In my case I was still hoping for something a little more "API like" and a little less "fire up some serverless functions." Ultimately, I think the Buttondown solution is quite good, especially if you're familiar with the tooling. It's possible we'll explore something more like that technique in the future.

Research time was building up when I took a flier, clicked on some "10 most amazing amazings" list in the search results, and magically found the cleverly named HTML/CSS To Image (HCTI). What a breath of fresh air! The idea is basically: create the social share preview you want on your server using the HTML and CSS that you know, ask HCTI to generate an image from that page you've created, and from then on that image exists on HCTI's heavily-cached servers for you to serve up indefinitely.

I mean look at this!

Pika Social Image Preview - shows a blog post title, the blog name, and an avatar

Further, the API is well documented and their support email is well monitored. Just the type of company we love to work with!

Let's get into the implementation! (In this case, we're speaking Ruby on Rails.)

First, we created a module that triggers generation of the social share image as well as some caching on our side to minimize calls over the wire to get that image. I'll comment a couple of things in this code:

module HtmlCssToImage
  AUTH = { username: Rails.application.credentials.htci.user_id,
           password: Rails.application.credentials.htci.api_key }.freeze

  CACHE_EXPIRATION = 6.weeks
  FALLBACK_IMAGE = 'image.png'.freeze

  def self.fetch_url(html:, css: nil, google_fonts: nil)
    return FALLBACK_IMAGE if AUTH[:username].blank? || AUTH[:password].blank?

    # This cache key will help us avoid calling over the wire to HCTI
    # when it isn't necessary. If the HTML or CSS change, HCTI will
    # generate an entire new image. Generally we want to see those changes
    # in the output, so we bust our cache as well.
    cache_key = "htmlcssimage/#{html}/#{css}/#{google_fonts}"

    image_url = Rails.cache.fetch(cache_key, skip_nil: true, expires_in: CACHE_EXPIRATION) do

      # The call to HCTI is pretty simple! We actually don't send any
      # google_fonts along, choosing to reference them with <link> tags
      # directly in the HTML we pass along. 
      response = HTTParty.post("https://hcti.io/v1/image",
                               body: { html: html, css: css, google_fonts: google_fonts },
                               basic_auth: AUTH)

      response['url']
    end

    image_url || FALLBACK_IMAGE
  end
end

Now we can use the fetch_url method in our SocialPreviewsController code. This is the controller that will get called when an app is looking for our “OG image” (more on that later). We have a few different actions in here to account for different types of things on Pika: posts, pages, etc. They all make use of this same respond_to block:

respond_to do |format|
  # The HTML view is used to preview the social share image in
  # our browser, but more importantly…
  format.html
  format.png do
    # The HTML view is sent to HCTI to be rendered into an 
    # image for social sharing
    html = render_to_string formats: :html
    
    # Here we call our HtmlCssToImage module (above) in order to
    # get a PNG image URL back from HCTI or our cache.
    redirect_to HtmlCssToImage.fetch_url(html: html, css: PNG_CSS),
                status: :found,
                allow_other_host: true
  end
end

The PNG_CSS is a thing that has lived on from this code we found in the Forem repo. I'm not designer, so I’m not sure what it does!

What does our HTML view look like? It’s not something worth sharing, really, but the basic idea is:

<%# A bunch of Ruby variables set up for theme colors and fonts %>

<style>
  /* 
    A bunch of styles, both for general design and referencing
    the color and font variables defined above.
  */
</style>

<div class="preview-div-wrapper">
  <div class="preview-div">
    
    <div class="title-area">
      <h1 style="font-size:<%= social_preview_font_size(@user.font, @post.title) %>vw;">
        <% if @post.title.present? %>
          <%= @post.title %>
        <% else %>
          <%= @post.body.to_plain_text.truncate(30, separator: ' ') %>
        <% end %>
      </h1>
    </div>
    <div class="user-area">
      <%= social_preview_avatar_image_tag(@user, 100) %>
      <%= @user.blog_name %>
    </div>

  </div>
</div>

When we introduced the avatar to the social share image, we found it challenging to test things in our local environment. HCTI did not have access to our local servers to find said avatar, so for development mode we sent the avatar image across using base64 representation as can be seen in the social_preview_avatar_image_tag helper method:

def avatar_to_data_uri(variant)
  bytes = variant.download
  content_type = variant.content_type
  "data:#{content_type};base64,#{Base64.encode64(bytes)}"
end

def social_preview_avatar_image_tag(user, size)
  if user.avatar.attached?
    variant = user.avatar.variant(:medium)
    url = if Rails.env.development?
      avatar_to_data_uri(variant)
    else
      rails_representation_url(variant)
    end
    image_tag url, alt: "This is your avatar", size: size, class: "avatar"
  end
end

Finally, we had to tie it all together by adding the og and twitter images:

<meta property="og:image" content="<%= post_social_preview_url(@post, format: :png) %>">
<meta name="twitter:image" content="<%= post_social_preview_url(@post, format: :png) %>">

And there it is. Posts (in this example) will have a social share URL pointing to our SocialPreviewsController, which will provide a HCTI PNG URL of a unique image for the post, which will display across the social-sphere.

I attempted to edit things down to their essence, leaving a lot of code out above. Feel free to reach out if you have any questions with how to implement social previews with HCTI!