Choosing a Pagination Strategy for Hugo List Pages
A decision guide for paginating Hugo list pages: compare built-in .Paginate, manual slicing, and client-side paging with a concrete template example, version-specific config, and a validation checklist.
19 May 2026, 07:48 UTC

The Decision: How to Paginate Static List Pages in Hugo
You need to split a large collection of pages—blog posts, documentation sections, or product listings—into multiple static URLs that search engines can crawl and users can bookmark. Hugo generates the entire site at build time, so any pagination must produce real HTML files, not rely on a server or client-side JavaScript to fetch the next chunk.
This guide compares the three practical approaches, shows the configuration and template code for the built-in method, and gives a checklist to verify the output before you deploy.
Constraints That Shape the Choice
- Build-time only: No runtime server, no API endpoints.
- SEO-friendly URLs: Each page must have a stable, crawlable URL (e.g.,
/blog/page/2/). - Version compatibility: Configuration keys changed between Hugo 0.119 and 0.120+; the installed version dictates which syntax works.
- Collection size: Very large collections (10k+ pages) increase build memory and time because pagination happens in memory.
Supported Options at a Glance
| Approach | How It Works | Generated URLs | SEO / Crawlability | Maintenance Effort |
|---|---|---|---|---|
Built-in .Paginate / .Paginator |
Call {{ $paginator := .Paginate .Pages }} in a list template; Hugo emits /section/page/N/ files automatically. |
Predictable, configurable path (page by default). |
Full: each page is static HTML with rel=next/prev and canonical tags. |
Low: one template call, config-driven page size. |
| Manual slice + custom routes | Use first/after or slice on .Pages and define custom output paths in front matter or permalinks. |
Fully custom (e.g., /blog/2024/, /blog/2023/). |
Full, but you must write rel links and canonicals yourself. |
High: template logic for slicing, URL construction, and navigation partials. |
| Client-side JavaScript paging | Load all items as JSON or hidden DOM, then show/hide via JS. | Single URL (e.g., /blog/ only). |
Poor: content beyond page 1 is invisible to crawlers and no-JS users. | Medium: requires JS bundle, API or inline data, and fallback handling. |
Trade-offs in Practice
Built-in pagination
Best for most sites. It’s fast, requires minimal template code, and produces the standard /page/N/ pattern that search engines expect. The paginator object exposes .Pages (current slice), .PageNumber, .TotalPages, .HasPrev, .HasNext, .Prev, and .Next—everything needed for navigation links.
Limitation: URL pattern is fixed to path/page/N/ (configurable path, not arbitrary slugs). Changing paginate or paginatePath after pages are indexed breaks existing URLs; plan redirects if you must change them.
Manual slicing
Choose this only when the URL scheme must differ from /page/N/—for example, year-based archives (/posts/2024/, /posts/2023/) or custom slugs per chunk. You’ll write the slicing logic, generate the correct permalinks or outputs in front matter, and manually add <link rel="next"> tags. It’s error-prone and adds template complexity.
Client-side paging
Not a like-for-like replacement. Use only for progressive enhancement (e.g., infinite scroll on top of static pages) or behind authentication where SEO doesn’t matter. Content hidden behind JS will not be indexed reliably.
Concrete Implementation: Built-in Pagination
1. Confirm your Hugo version
hugo version
# Example output: hugo v0.128.0-abc123456 linux/amd64 BuildDate=2024-06-15T10:30:00Z VendorInfo=gohugoio
If the version is 0.120 or newer, use the pagination config block. Older versions use top-level paginate and paginatePath keys.
2. Configure page size and path (Hugo ≥ 0.120)
# hugo.toml or hugo.yaml
[pagination]
pagerSize = 10 # items per page
path = "page" # URL segment, produces /section/page/2/
# disableAliases = true # optional: suppress /page/1/ alias
For Hugo < 0.120, the equivalent is:
paginate = 10
paginatePath = "page"
3. List template (e.g., layouts/_default/list.html or layouts/blog/list.html)
{{ $paginator := .Paginate .Pages }}
<ul class="post-list">
{{ range $paginator.Pages }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ if gt $paginator.TotalPages 1 }}
<nav class="pagination" aria-label="Pagination">
{{ if $paginator.HasPrev }}
<a href="{{ $paginator.Prev.URL }}" rel="prev">← Newer</a>
{{ end }}
{{ if $paginator.HasNext }}
<a href="{{ $paginator.Next.URL }}" rel="next">Older →</a>
{{ end }}
<span class="page-info">Page {{ $paginator.PageNumber }} of {{ $paginator.TotalPages }}</span>
</nav>
{{ end }}
.Paginate must be called on a page collection (.Pages, .Site.RegularPages, or a custom where result). Calling it on a single page or non-page slice will error at build time.
4. Optional: Section-specific page size
Override the global size for one section by passing a second argument:
{{ $paginator := .Paginate .Pages 5 }}
Validation Checklist
- Run the dev server and visit the section root and the second page:
hugo server # Open http://localhost:1313/blog/ and http://localhost:1313/blog/page/2/ - Count items on each page—should match
pagerSize(or the per-call override). - Inspect HTML source for:
<link rel="next" href="...">andrel="prev"on paginated pages- Canonical URL pointing to the current page (Hugo adds this automatically when
canonifyURLsis enabled) - No duplicate
<title>or meta description across pages
- Production build and verify emitted files:
Expecthugo --minify ls -la public/blog/page/1/index.html,2/index.html, etc. (orpage/2/index.htmldepending onuglyURLssetting). - Link check (optional but recommended):
# Quick curl test curl -I http://localhost:1313/blog/page/2/ # Should return 200, not 301/404/loop
Common Pitfalls and Mitigations
- Config key mismatch: Using
paginatein apaginationblock (or vice-versa) produces warnings or is ignored. Always match keys tohugo version. - Changing page size on a live site: Alters every paginated URL. If the site is already indexed, add redirect rules (Netlify
_redirects, Apache.htaccess, or Cloudflare Workers) from old/page/N/to new paths. - Huge collections: 20k+ pages can push build memory over default limits. Consider splitting into multiple sections or using
--renderToMemoryfor CI debugging. - Calling
.Paginatetwice in one template: The second call returns the same paginator but advances the internal pointer, causing duplicate or missing items. Assign once:{{ $paginator := .Paginate .Pages }}and reuse$paginator.
When to Revisit the Decision
- You need non-numeric, semantic URLs (e.g.,
/blog/category/go/page/2/)—still doable with built-in pagination via section templates, but manual slicing may be cleaner. - You migrate to a headless CMS and want to keep the same URL scheme—Hugo’s built-in pagination is portable because it’s just static files.
- Build time exceeds your CI budget—profile with
hugo --templateMetricsand consider reducingpagerSizeor splitting collections.
Bottom Line
For nearly every Hugo site, the built-in .Paginate / .Paginator pair is the right default: it’s zero-runtime, SEO-complete, and maintained by the Hugo core team. Reserve manual slicing for unusual URL requirements, and treat client-side paging as a UI enhancement—not a content delivery strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.