Enhance Hugo sites with Unpoly
I’m quite new to Hugo, my blog site also happens to be my first Hugo website, so I decided to play around and learn how to build static websites with it.
Being mainly a Ruby on Rails developer has its perks because we often find new tools to get things done elegantly. A few years ago, I stumbled upon a gem, and this time, surprisingly, it wasn’t a Ruby gem! It was the JavaScript library called Unpoly, this little but powerful library makes interacting with the DOM and HTML pages a lot more fun. If my memory serves me right Unpoly was designed to be an alternative to another JS library from the Rails ecosystem called Tubrolinks. It not only offers more features than Turbolinks but also competes well with its successor, Turbo.
What’s unique and common in these libraries is that they all alter the default navigation of web pages. Unpoly, for example, achieves this by enabling partial page updates through AJAX requests, effectively allowing developers to refresh specific sections of a page without the need for full page reloads. This approach results in a more dynamic and responsive user experience and also reduces the server and network load, contributing to faster and smoother interactions within the web application.
Let’s see what we can do with it in the world of Hugo!
As we already know, Hugo is just a tool for generating HTML pages from templates, that’s all. Anchor links in Hugo sites behave like normal website links - we send a GET request to a page at /post/enhance-hugo-unpoly
and the server responds back with the full HTML page. We can easily fit Unpoly JS in this flow here to fetch and inject content dynamically, eliminating the need for full page reloads. This will allow us to create better interactive features on static sites, such as dynamic forms or live search.
In this tutorial, we’ll enhance a Hugo website by adding two key features with the assistance of Unpoly. These are:
- Fragmented page loads
- Live search
The end result can be seen here: https://ganchdev.github.io/hugo-unpoly-tailwind/ Repository source: https://github.com/ganchdev/hugo-unpoly-tailwind
Install Unpoly JS
We first need to install Unpoly to our hugo website, this is the original installation instructions page. Many installation methods are supported depending on preference but for the sake of simplicity we’ll just pull the two minified (js and css) library files - they’re relatively tiny!
Add unpoly.min.js to
./static/js
Add unpoly.min.css to
./static/css
Then add them to the head.html
partial:
<!-- Styles -->
<link rel="preload stylehseet" as="style" href="{{ .Site.BaseURL }}css/unpoly.min.css">
<!---->
<!-- Scripts -->
<script src="{{ .Site.BaseURL }}js/unpoly.min.js"></script>
<!---->
Unpoly is now all set up in our Hugo website. To double-check, just go to the website in development, open the browser’s developer tools, and look out for this:
Fragmented page loads
Let’s start with the first feature – fragmented page loads. We can achieve this using custom HTML attributes provided by Unpoly’s API — up-follow
, up-preload
and up-instant
For example, we can modify the anchor tags in the ./layouts/_default/list.html
template for listing posts.
<div id="all-posts">
{{ $paginator := .Paginate $pages }}
{{ range $index, $page := $paginator.Pages }}
<section id="{{ .File.BaseFileName }}" data-title="{{ .Title }}">
<a up-follow up-preload up-instant href="{{ .Permalink }}">
<h2>{{ .Title }}</h2>
<p>{{ .Description }}</p>
<time>{{ .Date | time.Format ":date_medium" }}</time>
</a>
</section>
{{ end }}
</div>
up-follow
To enable fragmented loads, we’ll use the up-follow
attribute, which tells Unpoly to follow the link and update a specified HTML element with the content fetched from that link. In our case, we aim to update the primary element of our website layout, the default primary element is the <main>
element. However, we can customize it to target any other HTML element as needed.
up-main
To change the primary element, simply add up-main
to one of the outer tags within your baseof.html
template. Setting it will make Unpoly update this specific element.
up-prelaod
This option enables our links to preload the target page if hovered, creating an instant feel when clicked later. HTTP requests are made in the background and are kept cached, waiting for users to interact with the links.
up-instant
Follow the page links when mousedown
mouse event is triggered instead of click
Live search
In a statically generated Hugo site with no backend, getting the search feature to work in paginated lists takes a bit of creativity. Here’s the idea: we create a separate HTML page that renders all the posts, like a document structure for Unpoly to grab those post fragments and slot them right into the current page.
The flow in steps:
- We start by fetching a page, such as
/all
, which lists all available posts. - We then search for matches within the post titles based on the provided search query.
- When we find a match, we replace the current page’s content with the matched fragment from the
/all
page.
Create “All Posts” page
We can do this by creating a new file at ./layouts/section/all.html
, copying the structure from our ./layouts/_default/list.html
because we have to keep the same look as we’ll be replacing fragments with it. Here’s a snippet of what it might look like:
<div id="all-posts">
{{ range where .Site.Pages "Type" "post" }}
{{ if ne .File.BaseFileName "_index" }}
<section id="{{ .File.BaseFileName }}" data-title="{{ .Title }}">
<a up-follow up-preload up-instant href="{{ .Permalink }}">
<h2 >{{ .Title }}</h2>
<p>{{ .Description }}</p>
<time>{{ .Date | time.Format ":date_medium" }}</time>
</a>
</section>
{{ end }}
{{ end }}
</div>
We iterate through all the pages on the website (.Site.Pages
) that have a “Type” of “post” and, for each page that is not an index page (identified by its filename not being “_index”), we create a <section>
element and populate it with the post’s title, description, and date. These posts are wrapped in <a>
tags and have all Unpoly tags needed for fragmented page loads.
Add page fetching functionality
We’ll now need to add an input field to trigger the searching:
<input
name="search-posts"
id="search"
up-watch="searchPosts(value)"
up-watch-delay="300"
type="text"
placeholder="Search something.." />
up-watch
This tells Unpoly to watch our input field and run a callback when its value changes. The up-watch-delay
adds debouncing to the callbacks, i.e. wait 300 milliseconds every time the value changes before running the searchPost(value)
function.
Finally, let’s add the script
We can add this script to a partial in ./layouts/partials/script.html
and then include it in the baseof.html
template.
<script>
// Search for posts
async function searchPosts(value) {
// If no search value provided, return to the base URL
if (!value) { return await up.navigate({ url: window.baseUrl }) }
// Function to handle when a fragment is loaded;
// here we remove the pagination when fragments are
// inserted on the page
function fragmentLoaded() {
const pagination = document.querySelector(".posts-nav");
if (pagination) {
pagination.remove();
}
}
// Function to handle when a fragment is rendered
function fragmentRendered(renderResult) {
// Get the currently displayed sections on the page
const currentSections = document.querySelectorAll("section")
// Get the sections from the newly rendered fragment
const renderedSections = renderResult.fragment.querySelectorAll("section");
// Find sections with matching titles and get their IDs
const matchingSectionsIds = Array.from(renderedSections)
.filter((section) => {
const dataTitle = section.getAttribute('data-title');
return dataTitle.toLowerCase().includes(value.toLowerCase());
})
.map((section) => section.id);
// Remove sections that don't match the search criteria
currentSections.forEach((section) => {
if (!matchingSectionsIds.includes(section.id)) {
section.remove();
}
});
}
// Fetch the url and render the search results
// to the target id `all-posts` using Unpoly
await up.render({
target: "#all-posts",
url: `${window.baseUrl}all`,
onLoaded: fragmentLoaded,
onRendered: fragmentRendered
});
}
</script>
We’ve used a few more methods from the API here – up-navigate and up-render. Make sure you check out what they do and what their settings are; it’s all pretty intuitive once you’ve got the hang of how page fragments work.
The global window.baseUrl
as the example here is set in one of our templates because the value comes from Hugo itself.
<!-- Set JS Base URL -->
<script>
window.baseUrl = "{{ .Site.BaseURL }}";
</script>
<!---->
We’re done
That’s about it! It’s not too challenging to add a dynamic touch to static websites built with Hugo; it all comes down to HTML, JS, and CSS, after all. While Unpoly offers many more interesting features like dynamic forms and form validations, implementing them would require a working backend. I’ve chosen not to add any of these in here, as it diverges from the core purpose of Hugo.