Pablo Seminario
Post 23 Sep 2026 4 min

Parsing Twitter archives in Hugo using Content Adapters

X hides tweets from anyone without an account. So I exported 18 years of mine, dating back to when it was still Twitter in 2008, and rebuilt them as a plain, open website, no login wall, no algorithm, just my old tweets, generated straight from the archive with Hugo.

Content adapters were introduced in Hugo v0.126.0, released on May 14, 2024. As the documentation puts it:

A content adapter is a template that dynamically creates pages when building a site. For example, use a content adapter to create pages from a remote data source such as JSON, TOML, YAML, or XML.

That’s exactly the situation I was in: a JSON archive to turn into pages. To get that archive, first follow X’s own instructions to download your data.

Once the archive is unzipped, the interesting file is tweets.js, which has a structure like this

json json
[
  {
    "tweet" : {
      ...
    },
    "tweet" : {
      ...
    },
    "tweet" : {
      ...
    }
  }
]

an array holding the full history of your tweets, one tweet object per entry.

The interesting part is writing a content adapter that walks through that array and generates a page for every tweet. I put mine inside my chirp theme, as a _content.gotmpl file under the status content section:

golang golang
{{/* Content adapter: transforms tweets.js into status pages */}}
{{/* Filename: themes/chirp/content/status/_content.gotmpl */}}
{{ $res := resources.Get "tweets.js" }}
{{ $json := replaceRE `(?s)^\s*window\.YTD\.tweets\.part0\s*=\s*` "" $res.Content }}
{{ $json = replaceRE `;\s*$` "" $json }}
{{ $tweets := transform.Unmarshal $json }}

{{ $excludeRetweets := $.Site.Params.excludeRetweets }}

{{ range $tweets }}
  {{ $tweet := .tweet }}
  {{ $idStr := $tweet.id_str }}

  {{/* Skip retweets when excludeRetweets is enabled */}}
  {{ if and $excludeRetweets (hasPrefix $tweet.full_text "RT @") }}
    {{ continue }}
  {{ end }}

  {{/* Parse the Twitter date format: "Mon Jan 02 15:04:05 -0700 2006" */}}
  {{ $date := time $tweet.created_at }}

  {{/* Collect hashtags as lowercase tags */}}
  {{ $tags := slice }}
  {{ range $tweet.entities.hashtags }}
    {{ $tags = $tags | append (.text) }}
  {{ end }}

  {{/* Collect media from extended_entities (preferred) or fall back to entities */}}
  {{ $images := slice }}
  {{ $gif := "" }}
  {{ $video := "" }}
  {{ $mediaSource := $tweet.extended_entities }}
  {{ if not $mediaSource }}
    {{ $mediaSource = $tweet.entities }}
  {{ end }}
  {{ with $mediaSource }}
    {{ range .media }}
      {{ if eq .type "photo" }}
        {{ $images = $images | append (printf "tweets_media/%s-%s" $idStr (path.Base (urls.Parse .media_url_https).Path)) }}
      {{ else if eq .type "animated_gif" }}
        {{ $gif = printf "tweets_media/%s-%s" $idStr (path.Base (urls.Parse (index .video_info.variants 0).url).Path) }}
      {{ else if eq .type "video" }}
        {{ $video = printf "tweets_media/%s-%s" $idStr (path.Base (urls.Parse (index .video_info.variants 0).url).Path) }}
      {{ end }}
    {{ end }}
  {{ end }}

  {{/* Collect unique user mentions, excluding the site's own handle */}}
  {{ $mentions := slice }}
  {{ range $tweet.entities.user_mentions }}
    {{ $sn := .screen_name }}
    {{ if and (ne (lower $sn) (lower $.Site.Params.handle)) (not (in $mentions $sn)) }}
      {{ $mentions = $mentions | append $sn }}
    {{ end }}
  {{ end }}

  {{/* Build page params matching the status archetype */}}
  {{ $params := dict "draft" false "tweetID" $idStr "favoriteCount" ($tweet.favorite_count | default "0") "retweetCount" ($tweet.retweet_count | default "0") }}
  {{ if gt (len $tags) 0 }}
    {{ $params = merge $params (dict "tags" $tags) }}
  {{ end }}
  {{ if gt (len $mentions) 0 }}
    {{ $params = merge $params (dict "mentions" $mentions) }}
  {{ end }}
  {{ if gt (len $images) 0 }}
    {{ $params = merge $params (dict "images" $images) }}
  {{ end }}
  {{ if $gif }}
    {{ $params = merge $params (dict "gif" $gif) }}
  {{ end }}
  {{ if $video }}
    {{ $params = merge $params (dict "video" $video) }}
  {{ end }}

  {{/* Replace t.co short URLs with Markdown links: [display_url](expanded_url) */}}
  {{/* First, strip any t.co URLs that are just wrappers for attached media */}}
  {{ $text := $tweet.full_text }}
  {{ with $mediaSource }}
    {{ range .media }}
      {{ $text = replace $text .url "" }}
    {{ end }}
  {{ end }}
  {{ range $tweet.entities.urls }}
    {{ $mdLink := printf "[%s](%s)" .display_url .expanded_url }}
    {{ $text = replace $text .url $mdLink }}
  {{ end }}
  {{/* Replace #hashtag occurrences with links to the Hugo tag taxonomy pages */}}
  {{ range $tweet.entities.hashtags }}
    {{ $tag := strings.ToLower .text }}
    {{ $mdTag := printf "[#%s](/tags/%s/)" .text $tag }}
    {{ $text = replaceRE (printf `(?i)#%s\b` .text) $mdTag $text }}
  {{ end }}
  {{ $text = strings.TrimRight " \n" $text }}

  {{ $.AddPage (dict
    "path"    $idStr
    "dates"   (dict "date" $date)
    "params"  $params
    "content" (dict "mediaType" "text/markdown" "value" $text)
  ) }}
{{ end }}

With that adapter in place, Hugo turns every tweet in the archive into a page at build time, no intermediate script or generated files needed. My public X archive now lives under my own domain, at y.seminar.io 🎉