hugo-theme-devrel: the theme behind this site, now yours

Monday, Sep 7, 2026 | 10 minute read

David Pilato
hugo-theme-devrel: the theme behind this site, now yours

I spent the end of 2025 turning noti.st into this website. Then I spent the last few days doing the obvious next step: extracting the speaker bits into a theme other Developer Advocates can actually use.

It is called hugo-theme-devrel, it is MIT, and this page is running on it.

If you already have a Hugo site, the entire install is one module import. If you do not, there is a fictional exampleSite at devrel.hugo.pilato.fr so you can see every layout without cloning my biography.

Why a theme, not a gist of templates?

In January I wrote about migrating hundreds of talks into Hugo. The layouts lived in this repository: talk pages with PDF.js, a “Played N times” sidebar, a Leaflet map, multilingual templates, YouTube galleries, social embeds.

That works until a friend asks “can I reuse this?” and the honest answer is “sure, after you delete my face, my GCS bucket, and forty hardcoded assumptions.”

So the generic parts moved to github.com/dadoonet/hugo-theme-devrel. This repo keeps identity: content, data/socials.toml, params.author, the PDF bucket URL. The theme keeps the machinery.

It is not a fork of Dream. It is an overlay. Dream stays upstream; devrel imports it as a Hugo module and replaces the layouts that matter for a speaker site. You import one module:

[module]
  [[module.imports]]
    path = "github.com/dadoonet/hugo-theme-devrel"

Do not set theme = ["devrel", "dream"]. Dream arrives as a dependency. Attribution stays in the footer and in LICENSE-DREAM.

Yes, Cursor helped. I already confessed that habit in The Augmented Developer. The interesting part is not the AI, it is the split: theme vs site, so the next person does not have to replay my 200+ commits.

What you get

A DevRel homepage is not only a blog. Mine has to answer: where did you speak, with which slides, in which language, and is there a video?

The overlay adds:

  • Talks with conference city/country, optional coordinates, PDF slides, YouTube, co-speakers, and social: URLs (X, Bluesky, LinkedIn).
  • Talk templates — one canonical abstract, EN/FR tabs, a “Raw” view for CFPs, and every occurrence of that talk.
  • A map built from conference.latitude / longitude. No extra geo file.
  • A videos page grouped by year.
  • An all-talks archive grouped by year, with a map for each year.
  • About assembled from numbered Markdown files (10-me.md, 20-details.md, …).
  • Pagefind search — the loupe in the nav, or Ctrl/Cmd+K.
A talk page: slides in the middle, “Played N times” and “Gave N talks at this conference” on the side.

A talk page: slides in the middle, “Played N times” and “Gave N talks at this conference” on the side.

Hugo is a static database too

I already used that phrase in January. Here is the version with the SQL I keep in my head next to the templates that actually ship.

A speaker site is a few hundred events, some videos, a map. You do not need a cluster for that. You need rows. In this theme, a talk is a page bundle. The Markdown file is the abstract. A cover.* next to it is an image column you do not even have to name. The row is the front matter. Here is a complete one:

---
title: "Elasticsearch Query Language: ES|QL"
conference:
  name: "JUG Summer Camp"
  city: "La Rochelle"
  country: "France"
  country_code: "fr"
  url: "https://www.jugsummercamp.org/"
  latitude: "46.160329"
  longitude: "-1.151139"
authors:
  - author: "David Pilato"
date: 2024-09-06
talk-lang: fr
talk: "ES|QL"
pdf: "2024/2024-09-06-jug-summer-camp.pdf"
youtube: "Fa6ICBs1KM0"
links:
  - title: "ES|QL documentation"
    url: "https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html"
    description: "The official guide"
social:
  - "https://x.com/dadoonet/status/1827998166865637459"
  - "https://bsky.app/profile/klf37.bsky.social/post/3muol6taevk2h"
  - "https://www.linkedin.com/embed/feed/update/urn:li:activity:7501648269345812481"
---

talk is the foreign key to the canonical abstract. youtube is nullable. conference.country / latitude / longitude are why the map exists. authors can list a co-speaker; avatar: is optional when the name matches params.author or static/speakers/firstname_lastname.*.

Hugo does not query this at request time. At build time it is a database anyway: where, GroupByDate, a scratch map for DISTINCT. Same ideas as SELECT / GROUP BY, run once, baked into HTML.

Every talk, grouped by yearGROUP BY year(date):

SELECT year(date) AS year, count(*) AS talks
FROM talks
GROUP BY year
ORDER BY year DESC;
{{ $talks := site.GetPage "/talks" }}
{{ range $talks.RegularPages.GroupByDate "2006" }}
  <h2>{{ .Key }} ({{ len .Pages }})</h2>
  {{ range .Pages }}
    <a href="{{ .RelPermalink }}">{{ .Title }}</a>
  {{ end }}
{{ end }}

GroupByDate "2006" is Hugo-speak for year(date). .Key is 2024, 2025, 2026. That is /talks/all.

Only talks with a recordingWHERE youtube IS NOT NULL:

SELECT *
FROM talks
WHERE youtube IS NOT NULL
ORDER BY date DESC;
{{ $talks := site.GetPage "/talks" }}
{{ range $talks.RegularPages }}
  {{ if .Params.youtube }}
    <a href="{{ .RelPermalink }}#video">{{ .Title }}</a>
  {{ end }}
{{ end }}

Then GroupByDate again if you want the same year jump. That is /talks/videos.

Countries I have stood inSELECT DISTINCT country:

SELECT DISTINCT country
FROM talks
WHERE country_code <> 'online';
{{ $talks := site.GetPage "/talks" }}
{{ $scratch := newScratch }}
{{ range $talks.RegularPages }}
  {{ with .Params.conference.country }}
    {{ $scratch.SetInMap "countries" . . }}
  {{ end }}
{{ end }}
{{ range $scratch.Get "countries" }}
  {{ . }}
{{ end }}

Add latitude / longitude and you have pins. That is /talks/map.

The same talk, many roomsGROUP BY talk:

SELECT talk, count(*) AS played
FROM talks
GROUP BY talk
ORDER BY played DESC;
{{ $name := "ES|QL" }}
{{ $played := where (where site.RegularPages "Section" "talks") ".Params.talk" $name }}
Played {{ len $played }} times

That is the “Played N times” sidebar, and /talks/templates.

QuestionSQL shapePage that renders it
Every talk, split by yearGROUP BY year(date)/talks/all
Talks that have a recordingWHERE youtube IS NOT NULL/talks/videos
Countries I have spoken inSELECT DISTINCT country/talks/map
Every time I gave the same talkGROUP BY talk/talks/templates

No extra JSON, no CMS, no geo file. The “schema” is the YAML. The “queries” are the layouts. Change a talk, rebuild, the aggregations move.

The speaker archive is three indexes

Those queries are the pages I actually live in. /talks is only the landing page (featured cards, then the rest). The satellites are the SELECTs above, with CSS.

/talks/all is the GROUP BY year archive: jump links per year (with counts), then a card grid — cover, language, slides/video badges, conference, date. Scroll a year and you get a map of that year only (the same DISTINCT city query, filtered by date), which is different from the global /talks/map.

/talks/all — year navigation and the 2026 card grid. Badges tell you if slides or a recording exist.

/talks/all — year navigation and the 2026 card grid. Badges tell you if slides or a recording exist.

Same page, a bit lower: the 2026 map. Eight talks, seven cities, two countries, one online.

Same page, a bit lower: the 2026 map. Eight talks, seven cities, two countries, one online.

/talks/videos is the WHERE youtube IS NOT NULL list. Same year jump, red accents, 16:9 cards, click through to #video on the talk.

/talks/videos — 100 recordings on this site, grouped by year.

/talks/videos — 100 recordings on this site, grouped by year.

/talks/templates is the GROUP BY talk catalog, sorted by last played date. Open one template and you get stats, language tabs, Talk vs Raw (the CFP paste view), and every conference where that talk ran.

/talks/templates — 23 topics. “Played 220 times” is not a flex, it is a sorting key.

/talks/templates — 23 topics. “Played 220 times” is not a flex, it is a sorting key.

One template: first/last dates, EN/FR, Talk vs Raw. The conference list is further down the page.

One template: first/last dates, EN/FR, Talk vs Raw. The conference list is further down the page.

The global talks map at /talks/map: SELECT DISTINCT city and country, with coordinates from the same front matter.

The global talks map at /talks/map: SELECT DISTINCT city and country, with coordinates from the same front matter.

Search that does not need Elasticsearch (yes, I know)

I ship Elasticsearch for a living. This site is still a pile of static HTML on GitHub Pages. Pagefind indexes the public folder after hugo and the theme wires the Component UI: a centered modal, covers in the results, section filters (posts, talks, templates, videos, about) once you type, type icons that survive dark mode.

Hit Ctrl+K or Cmd+K. Or click the loupe.

Pagefind modal after typing “elasticsearch”. Filters appear on the same row as the query.

Pagefind modal after typing “elasticsearch”. Filters appear on the same row as the query.

The build is two steps:

# Generate static HTML pages so they can be indexed
hugo --minify
# Generate the Pagefind index
npx pagefind --site public

Empty queries (browse / filter only) sort by date. As soon as you type, relevance wins. That matches how I actually use a speaker archive: “what did I say about ES|QL last year?” vs “show me everything tagged talks.”

The example site is no longer a stub

The first version of exampleSite had one post and two talks. That is enough to prove the module loads. It is not enough to see the map, the “Played N times” sidebar, co-speakers, or search covers.

It now follows a fictional advocate named Alex Rivera: blog posts, talks in several cities and online, bilingual templates, a co-speaker, recordings, and social embeds. Enough to walk /talks, /talks/all, /talks/videos, /talks/templates, and search without cloning my biography.

Alex does not exist. Copy the folders, not the bio. Source: exampleSite. Live demo: devrel.hugo.pilato.fr.

The homepage is still a Dream post grid. The overlay is most visible once you leave / for /talks.

The homepage is still a Dream post grid. The overlay is most visible once you leave / for /talks.

How to try it

1. Download Hugo

Install the extended edition from the Hugo installation guide. Dream compiles CSS at build time, so a non-extended binary will fail. Check with hugo version; the line should mention extended. GitHub Pages builds of this site use the extended Linux binary; locally, pick the extended package for your OS.

2. Turn the site into a Hugo module

Skip this if go.mod already exists.

hugo mod init github.com/you/your-site

3. Import the theme

In hugo.toml (or hugo.yaml), import only devrel. Dream arrives as a module dependency. Do not set theme = ["devrel", "dream"].

[module]
  [[module.imports]]
    path = "github.com/dadoonet/hugo-theme-devrel"

Then:

hugo mod get github.com/dadoonet/hugo-theme-devrel

4. Set your identity

Still in hugo.toml:

[params]
  author = "Your Name"
  avatar = "/about/you.avif"
  headerTitle = "Your Name"
  motto = "Developer Advocate"
  email = "you@example.org"
  siteStartYear = 2024

[params.talks]
  # Prefix for talk pdf: paths. Empty = a PDF in the page bundle or a site-relative file.
  pdf_base_url = ""

Put the avatar file at static/about/you.avif (or whatever path you set). params.author / params.avatar are the defaults for archetypes and for talk bylines when you omit avatar: in front matter.

5. Add the talk index pages

The overlay expects a few _index.md files. Copy them from exampleSite/content/talks/ or create them:

PathFront matter
content/talks/_index.mdtitle: "Talks"
content/talks/all/_index.mdlayout: "all"
content/talks/map/_index.mdlayout: "map"
content/talks/videos/_index.mdlayout: "videos"
content/talks/templates/_index.mdlayout: "templates"

Example — content/talks/all/_index.md:

---
title: "All talks"
layout: "all"
---

Search is shipped by the theme as content/search/_index.md. Override that file in your site if you need a custom title; disable with params.search.enabled = false.

6. Create a talk

hugo new talks/2026/2026-09-08-my-conference/index.md

Minimal front matter — a fuller row is in Hugo is a static database too. A cover.* file in the same folder is picked up automatically; avatar: is inferred from the author name:

---
title: "Talk Title"
conference:
  name: "Conference Name"
  city: "City"
  country: "Country"
  country_code: "fr"       # ISO code, or "online"
  url: "https://example.org/event"
  latitude: "48.856614"    # optional — used by the map
  longitude: "2.352222"
authors:
  - author: "Your Name"
date: 2026-09-08
talk-lang: en
talk: "Topic Name"         # groups occurrences + links to the template
youtube: "VIDEO_ID"        # optional
pdf: "2026/2026-09-08-my-conference.pdf"
---

Optional social: is a list of public X, Bluesky, or LinkedIn post URLs; the theme embeds them on the talk page.

For a recurring topic, add content/talks/templates/<slug>/index.md with layout: "template" and the same talk: value. The README has the full YAML, including EN/FR versions:.

7. Build the HTML, then the search index

Pagefind reads the generated public/ folder, so Hugo must run first:

# Generate static HTML pages so they can be indexed
hugo --minify
# Generate the Pagefind index
npx pagefind --site public

For local preview, run hugo server after you have indexed once. Put the same two commands in CI (or a package.json build script) so GitHub Pages stays searchable.

A Github Actions workflow example is in .github/workflows/pages.yml.

8. Steal the example, not the biography

If you prefer a known-good tree to a blank hugo new, copy exampleSite and replace Alex Rivera. The live preview is devrel.hugo.pilato.fr.

If you are a Developer Advocate who is tired of a speaker page that only exists in a slide appendix: clone it, delete Alex, put your talks in content/talks/YYYY/. And if you find a bug, open an issue — I will probably fix it with the same tool that helped me extract the theme in the first place. 😉

© 2010 - 2026 David Pilato

Search is powered by Pagefind. Just hit CTRL+K or CMD+K to start searching.

Powered by Hugo with Dream and Devrel themes.

Details

I discovered Elasticsearch project in 2011. After contributed to the project and created open source plugins for it, David joined elastic the company in 2013 where he is Developer and Evangelist. He also created and still actively managing the French spoken language User Group. At elastic, he mainly worked on Elasticsearch source code, specifically on open-source plugins. In his free time, he likes talking about elasticsearch in conferences or in companies (Brown Bag Lunches AKA BBLs). He is also author of FSCrawler project which helps to index your pdf, open office, whatever documents in elasticsearch using Apache Tika behind the scene.

Who am I?

Developer | Evangelist at elastic and creator of the Elastic French User Group. Frequent speaker about all things Elastic, in conferences, for User Groups and in companies with BBL talks. In my free time, I enjoy coding and deejaying as DJ Elky, just for fun. Living with my children in Cergy, France.

Social Links