Open Graph Tags: Making Your Pages Look Right When Shared

Last edited: November 7, 2026

You finish a post, paste the link into Slack or WhatsApp, and the preview shows the wrong title, no image, or worse, the first line of your navigation menu. That gap between the page and the card is where Open Graph lives.

Open Graph is a set of meta tags that tell other platforms what your page is about, so that when someone shares it they get a specific title, description and image instead of a guess. Facebook introduced the protocol in 2010, and most platforms now read it: LinkedIn, Slack, WhatsApp, Telegram, Discord, Signal, iMessage and X. It is one of the cheapest pieces of metadata work you can do, and one of the most commonly half-done.

This post covers what the tags do, which ones actually matter, how to write them so the card survives, and how to emit them from Hugo.

What a crawler actually sees

When you paste a link, the platform fetches the HTML with a server-side request and parses the <head>. It does not log in, it usually does not run your JavaScript, and it does not wait for anything clever.

That single fact explains most broken previews. Facebook, LinkedIn, Slack and WhatsApp read the raw markup your server returns. If your tags are injected client side by a React app after hydration, they see an empty head and fall back to whatever they can scrape. This is why single page applications so often produce cards with the site name and nothing else.

The four required properties

Open Graph defines a small set of required properties. Everything else is optional.

The optional ones that are worth the keystrokes: og:description, og:site_name, og:locale, and og:image:alt.

Two formatting notes. Open Graph properties use the property attribute, not name:

<meta property="og:title" content="Open Graph Tags: Making Your Pages Look Right When Shared">
<meta property="og:type" content="article">
<meta property="og:url" content="https://pascalnjue.com/blog/open-graph-tags/">
<meta property="og:image" content="https://pascalnjue.com/images/open-graph-card.jpg">

Some parsers tolerate name="og:title" because browsers do not care, but the specification says property and the reference implementation expects it. Do not rely on leniency.

And og:url should agree with your <link rel="canonical">. When the two disagree, crawlers pick one and you lose control of which URL gets associated with the card.

Descriptions that survive truncation

Cards cut text. Most platforms show somewhere around 120 to 160 characters before they stop, and they stop without warning, often mid-word.

Write og:description as one specific sentence that stands on its own. If you leave it out, platforms fall back to the first text they find on the page. On a marketing site that is frequently the cookie banner, and on a blog it is often the first paragraph, which may open with “You finish a post” and make no sense out of context.

One thing worth knowing: Google ignores Open Graph tags for search ranking. Your meta description is what search results use. The two should be written for different audiences even when they say similar things.

The image does most of the work

The image is what people actually react to, so give it the space it needs.

Also set og:image:width and og:image:height when you know them. They are optional, but they let a crawler reserve the right space before the image downloads, which means fewer layout surprises.

Then set og:image:alt. It is read by screen readers, and several platforms display it when the image fails to load. It costs one line.

Tags for articles

If og:type is article, you can add a few article specific properties:

<meta property="article:published_time" content="2026-10-18T09:00:00+03:00">
<meta property="article:modified_time" content="2026-10-18T09:00:00+03:00">
<meta property="article:author" content="https://pascalnjue.com/about/">
<meta property="article:section" content="Engineering">
<meta property="article:tag" content="open graph">

Use ISO 8601 timestamps with a timezone, not a human formatted date. A date like “18 Oct 2026” is ambiguous across parsers, and some will simply drop the property.

X still wants its own tags

X (formerly Twitter) reads Open Graph as a fallback for title, description and image, but the card layout is decided by twitter:card. If that tag is missing you get a small summary card, which is not what most people want.

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Open Graph Tags: Making Your Pages Look Right When Shared">
<meta name="twitter:description" content="A practical guide to Open Graph meta tags, image sizing, and the mistakes that break link previews.">
<meta name="twitter:image" content="https://pascalnjue.com/images/open-graph-card.jpg">
<meta name="twitter:image:alt" content="A monitor showing a link preview card">

Note that these use name, not property. That inconsistency is in the specification, not a typo.

Where it usually breaks

Seven failures account for most bad previews:

  1. Tags are rendered by JavaScript. Most crawlers never execute it. Server render, or use static markup.
  2. og:image is a relative path like /images/card.jpg. It must be absolute, including the scheme and host.
  3. You are testing on localhost. The crawler cannot reach your laptop. Test against a deployed URL, even a preview one.
  4. The platform cached an old scrape. Facebook and LinkedIn cache aggressively; Slack caches unfurls. Fix the page, then force a re-scrape.
  5. Two sources emit the same tag. A theme and a plugin each printing og:title gives you duplicates, and the first one wins. It is usually the theme’s homepage title.
  6. Bot protection blocks the crawler. A Cloudflare challenge or a WAF rule returns a challenge page instead of your HTML, so there is no image and no description.
  7. og:url, the canonical tag and the actual host disagree. Pick one host, one scheme, one trailing slash policy, and apply it everywhere.

Doing it in Hugo

Hugo ships internal templates for both protocols, which is the fastest path:

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

Put them inside the <head> of layouts/_default/baseof.html. They read the page title, description, permalink and the images front matter parameter. If a page defines no image, you get no og:image, so decide on a site wide fallback before shipping.

If you want control over fallbacks and exact tag order, write a partial. Create layouts/partials/opengraph.html:

{{ $title := .Title }}
{{ if .IsHome }}{{ $title = site.Title }}{{ end }}
{{ $description := or .Description .Summary site.Params.description | plainify | truncate 200 "" }}
{{ $image := or .Params.image site.Params.ogImage }}

<meta property="og:site_name" content="{{ site.Title }}">
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $description }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:locale" content="{{ site.Language.LanguageCode }}">
{{ with $image }}
<meta property="og:image" content="{{ . | absURL }}">
<meta property="og:image:width" content="{{ $.Params.image_width | default 1200 }}">
<meta property="og:image:height" content="{{ $.Params.image_height | default 630 }}">
<meta property="og:image:alt" content="{{ $.Params.image_alt | default $.Title }}">
{{ end }}

Then call it from the head:

{{ partial "opengraph.html" . }}

And give each post the fields it needs:

+++
title = 'Open Graph Tags: Making Your Pages Look Right When Shared'
description = 'A practical guide to Open Graph meta tags: required properties, image sizing, common mistakes, and Hugo templates.'
date = 2026-10-18
image = '/images/open-graph-card.jpg'
image_width = 1200
image_height = 630
image_alt = 'A link preview card rendered next to a laptop'
+++

The absURL call is the part that matters. site.BaseURL is already https://pascalnjue.com/, so a path like /images/open-graph-card.jpg becomes a fully qualified URL without hardcoding the host anywhere in the content.

One caution if you build in CI: check which Hugo version the deploy pipeline pins. A template using newer functions will build locally and fail on the runner, which is an annoying way to learn about version drift.

Verify it for real

Do not trust the markup by eye. View it after a build:

curl -s https://pascalnjue.com/blog/open-graph-tags/ | grep -E 'og:|twitter:'

You should see one of each, all absolute, all pointing at the canonical URL. Then run the URL through the Facebook Sharing Debugger and the LinkedIn Post Inspector. Both show exactly what their crawler parsed, and both let you force a fresh scrape.

For a quick visual check across several platforms at once, opengraph.xyz is useful. After that, the honest test is the one you will actually experience: paste the link into Slack, WhatsApp and a direct message to yourself, and look at what appears. That is the card your readers will see.

The whole job is about ten lines of metadata and one decent image. It takes less time than writing the post’s description, and it is what most people will judge the post by before they read a word of it.