Twitter Cards in Hugo: Avoid the relURL and absURL pitfalls

I recently published a new blog post and noticed my Twitter Cards were broken.

broken-twitter-card

What are Twitter Cards you may be wondering? Great question! When you have Twitter Cards configured properly and you share a link on Twitter, you’ll see an image with the Tweet and link to whatever it is you’re sharing.

working-twitter-card

As someone who is very visual I didn’t like that my Twitter Cards weren’t showing any images. In fact, the link looked boring and I do feel having a catchy image often helps pique curiosity so people want to click the link I share.

I noticed I had Twitter card metadata configured in my Hugo template, but the links being used were relative links (using Hugo’s built in relURL function). Twitter cards don’t work with relative links - they need to be absolute. While I could just change the pipe to use Hugo’s built in absURL function, I ended up redesigning the structure of my Hugo website.

At some point in the past few years Hugo introduced the concept of Page Bundles and while I used them on other websites I designed, I never got around to updating this website…until now.

As someone who is very ADHD, having resources local to the corresponding folder is so helpful. Previously I kept all images for all blog posts in a central folder located in my ./static directory, but I didn’t like this configuration. I preferred to have my images located in the same folder as the blog post they correspond to so I whipped up a janky AF python script and restructured my data.

This meant my post structure now looked like this:

content/
├── posts
│   ├── my-post
│   │   │ 
│   │   ├── image-1.jpg
│   │   ├── image-2.png
│   │   └── index.md
│   └── my-other-post
│       └── index.md
└── another-section
    ├── foo.md
    └── not-a-leaf-bundle
        ├── bar.md
        └── another-leaf-bundle
            └── index.md

This new post data structure meant I also had to change the code I used for my Twitter Cards and OpenGraph metadata.

Previously my twitter:image metadata Hugo code looked like this:

  {{ if .IsHome}}
    <meta name="twitter:image" content="{{ .Site.Params.hero.hero__image | relURL }}">
  {{ else }}
    <meta name="twitter:image" content="{{ .Params.Image | relURL }}">
  {{ end }}

Because of the new data structure, this wouldn’t work. There are also better ways to handle the scenario of what if .Params.Image didn’t exist on a particular post, which I previously didn’t handle at all.

Now, Hugo provides an _internal/twitter_cards.html and _internal/opengraph.html partial you can use by placing the following shortcode at the top of your head.html partial for your theme:

{{ template "_internal/opengraph.html" . }}
{{ template "_internal/twitter_cards.html" . }}

However, I discovered this also didn’t work well for me for a few reasons:

  1. The image specified in the post frontmatter needs to be named cover or thumbnail.
    • This site has been migrated from Wordpress, to Ghost, and subsequently Hugo so the image names used in past posts are completely random.
  2. The post frontmatter needs to include images instead of image and be in a list or an array format.
  3. If the above criteria isn’t met, then the post frontmatter needs to have a feature entry for the desired image.
  4. Someone else owns that shortcode/template and if they make upstream changes, it could break the way Twitter cards and Opengraph card work on my site.

While I could have whipped up a python script to further restructure my data and solve the issue for reasons 1-3, I opted to just write my own Twitter card configuration. Before I jump to the solution, it’s important to understand just why the previous configuration of <meta name="twitter:image" content="{{ .Params.Image | relURL }}"> didn’t work. Let’s take a look at a snippet of my post frontmatter after I moved to page bundles:

---
title: "Twitter Cards with Hugo"
author: "jldeen"
image: "image.png"
layout: post
---

You can see the image: key is a relative path because the image is now in the same directory as the index.md file. This means when using {{ .Params.Image | relURL }} the metadata produced for twitter:image becomes /image.png. Now if I update the function to use absURL, the metadata produced for twitter:image becomes https://jessicadeen.com/cover.png. Neither of those URLS are correct or point to the correct location, but that’s okay. We can fix that. Here’s what I came up with:

{{ $siteURL := .Site.BaseURL  }}
{{ $relPermalink := .RelPermalink}}
{{- with .Params.share_img | default .Params.image | default .Site.Params.logo }}
{{ $image := . }}
  {{- if not (strings.HasPrefix $image "http") }}
  {{ $image := urls.JoinPath $siteURL $relPermalink $image }}
  <meta name="twitter:image" content="{{ $image }}" />
  {{- else }}
  <meta name="twitter:image" content="{{ $image }}" />
  {{- end }}
{{- end }}

Let’s break this down.

I start off by setting a few environment variables:

  • siteURL, which equals the value set in my config.yaml file under BaseURL
  • relPermalink, which equals the value of the relative permalink of the current page.

Wrapping this up in a with function, the code can use .Params.share_img, .Params.image or .Site.Params.logo, and assuming any of those 3 params exist, the next block of code will run.

If the image found does not have an http prefix, set another variable and use the urls.JoinPath Hugo function to create a valid URL: baseurl + relative permalink for the current page + image name found. An example of what that URL would look like is: https://jessicadeen.com/posts/2024/twitter-cards-hugo/cover.png. Now, in the event the image found does have an http prefix, great, just use the image link found.

The final piece is of course to test these changes. I could just run hugo serve and view my site’s metadata which would show something like this:

<meta property="og:title" content="Twitter Cards with Hugo" />
<meta property="og:description" content="Twitter Cards I recently published a new blog post and noticed my Twitter Cards were broken.
What are Twitter Cards you may be wondering? Great question! When you have Twitter Cards configured properly and you share a link on Twitter, you&rsquo;ll see an image with the Tweet and link to whatever it is you&rsquo;re sharing.
As someone who is very visual I didn&rsquo;t like that my Twitter Cards weren&rsquo;t showing any images." />
<meta property="og:type" content="article" />
<meta property="og:url" content="http://localhost:1313/posts/2024/twitter-cards-hugo/" />
<meta property="og:image" content="http://localhost:1313/posts/2024/twitter-cards-hugo/image.png" />
<meta property="article:section" content="posts" />
<meta property="article:published_time" content="2024-04-04T19:41:09+00:00" />
<meta property="article:modified_time" content="2024-04-04T19:41:09+00:00" />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="http://localhost:1313/posts/2024/twitter-cards-hugo/image.png" /><meta name="twitter:title" content="Twitter Cards with Hugo"/>
<meta name="twitter:description" content="Twitter Cards I recently published a new blog post and noticed my Twitter Cards were broken.
What are Twitter Cards you may be wondering? Great question! When you have Twitter Cards configured properly and you share a link on Twitter, you&rsquo;ll see an image with the Tweet and link to whatever it is you&rsquo;re sharing.
As someone who is very visual I didn&rsquo;t like that my Twitter Cards weren&rsquo;t showing any images."/>
<meta name="twitter:site" content="@jldeen"/>

That’s great, but I’m a visual learner and that’s a lot of HTML to look at very closely. Twitter used to have a Twitter Card validator, but they deprecated that and now suggest you just use the Tweet Composer built into Twitter if you want to see how your Twitter Cards will look. I found that option clunky so I used ThreadCreator’s tool to handle the visual preview/testing:

preview

It also allows you to easily see the metadata found for opengraph and Twitter.

metadata

This solution works seamlessly on every page of my site and it handles two key scenarios:

  1. If it’s the root page of my site, it uses the image specified in my config.yaml file under .Params.Logo.
  2. If it’s a blog post, it uses the image specified in the frontmatter of that specific post.

I then created a custom partial called twitter.html, updated the Twitter metadata accordingly, and placed it in my layouts/partials folder. Then, in my head.html file, I included the partial by calling {{ partial "partials/twitter.html" . }}. I did the same for OpenGraph.

This approach gives me full control over the Twitter Card configuration, eliminates the risk of any breaking changes that could be caused by upstream Hugo updates, and avoids any relURL or absURL issues.