Pagination
Introduction
ReeWeb generates paginated routes statically: a listing route such as /blog/ is split into numbered pages (/blog/, /blog/2/, /blog/3/, …), each its own HTML file. Pagination is declared once in config/pagination.ts and rendered both by the SSG script (scripts/ssg.ts) and the dev server (scripts/dev.ts), so the two produce identical pages.
The system is deliberately decoupled into three layers:
- Config (
config/pagination.ts) - which routes paginate and how. - Records - where the items come from. Markdown files by default (
lib/collect_records.ts), or a customload_records(lang)loader for external sources. - View (
lib/pagination.ts+ the shipped components) - a pure view-model and the<full-pagination>/<simple-pagination>components that render it. The view layer knows nothing about the data source - only how many records there are and how to build a page URL.
Configuration
config/pagination.ts is the single source of truth. The default config paginates the markdown blog:
export const pagination: PaginationConfig = {
enabled: true,
per_page: 10,
path_segment: "", // /blog/2/ - no untranslated "page" word in the URL
show_when_single_page: false,
always_show_prev_next: true,
variant: "full", // "full" or "simple"
routes: [{ route: "blog" }],
};
PaginationConfig
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Global master switch. false builds no pagination pages at all. |
per_page | number | 10 | Default items per page. Routes can override via per_page. |
path_segment | string | "" | URL segment before the page number. "" → /blog/2/; "page" → /blog/page/2/ (the word is not localized). |
show_when_single_page | boolean | false | Render the pagination element even when all results fit on page 1. Matches Laravel's hasPages(). |
always_show_prev_next | boolean | true | Always render Previous/Next, disabled at the first/last page. |
variant | "full" | "simple" | "full" | Which shipped component the route's index template uses. |
routes | PaginationRoute[] | [] | Registered routes - markdown folders and/or loader-backed routes. |
The page-number window (how many links show around the current page) is not a global option - it is set per-component via the
on-each-sideattribute. See Components.
PaginationRoute
| Field | Type | Default | Description |
|---|---|---|---|
route | string | - | Base route to paginate. Also the output location and the directory holding the index.ree rendered per page. "blog" → /blog/, /blog/2/, /en/blog/2/. |
source | "markdown" | "loader" | auto | Where records come from. Omit to auto-detect: the loader is used when the route's index.ts exports load_records, otherwise the markdown collector. |
per_page | number | global per_page | Per-route override of items per page. |
sort | "date_desc" | "date_asc" | "filename" | "date_desc" | Sort order for the default markdown collector. |
per_page precedence
The effective page size for a route is resolved during static site generation in this order (first wins):
- A literal
per-page="N"attribute on the pagination component in the route'sindex.reesource. - The route's
per_pageinconfig/pagination.ts. - The global
per_page.
The component attribute is read by scanning the template text (read_per_page_override in lib/pagination.ts), because page count and record slicing are decided before the template renders. The value must therefore be a literal positive integer - template expressions are ignored.
URL scheme
Page 1 lives at the route's normal index location; later pages append the page number (with the optional path_segment in between):
| Page | path_segment: "" | path_segment: "page" |
|---|---|---|
| 1 | /blog/ | /blog/ |
| 2 | /blog/2/ | /blog/page/2/ |
| 3 | /blog/3/ | /blog/page/3/ |
Non-default languages are prefixed as usual: /en/blog/2/. Each numbered page is its own canonical URL (every page is indexable), with full hreflang alternates.
With an empty
path_segment, a record whose slug is purely numeric (e.g. a post literally named2) would collide with a page URL. Avoid purely-numeric slugs in paginated routes.
Record sources
A paginated route gets its records from one of two sources, chosen by source (or auto-detected).
Markdown (default)
lib/collect_records.ts walks the route's directory and collects one record per .md file. It:
- Excludes the route's own listing index (
blog/index.md,blog/01_index.md) but keeps folder-per-post entries (blog/my-post/index.md). - Resolves per-language variants (
{name}.{lang}.md→{name}.{default}.md→{name}.md). - Filters by content visibility - drafts and future-dated posts are dropped from listings and feeds.
- Sorts newest-first by default.
Each collected record (CollectedRecord) has:
| Field | Type | Description |
|---|---|---|
rel_path | string | Source path relative to the public dir. |
canonical_path | string | Language-agnostic URL path (e.g. /blog/my-post). |
title | string | Frontmatter title, else first # Heading, else the slug. |
description | string | description/excerpt/summary/abstract, else first paragraph; truncated to 320 chars. |
content_html | string | Rendered markdown body HTML. |
published_at | Date | published_at/date, else file mtime. |
authors | Author[] | Normalized { name, email?, url? } list. |
Loader
For records that don't come from markdown (an external API, a database, a static array, or a custom shape), export load_records(lang) from the route's index.ts:
// src/public/blog/index.ts
export async function load_records(lang: string): Promise<BlogCard[]> {
// fetch / read / build records however you like
return records;
}
The loader is used automatically when present, or explicitly with source: "loader". It may return records of any shape - the template consumes whatever fields the loader provides. If source: "loader" is set but no load_records is exported, the script warns and falls back to the markdown collector.
Components
Two components ship in src/components/. Both are custom elements that receive the PaginationData view-model via the data attribute:
<full-pagination data="{= props.pagination }"></full-pagination>
<simple-pagination data="{= props.pagination }"></simple-pagination>
| Component | Description |
|---|---|
<full-pagination> | Numbered paginator (Laravel Tailwind paginator reconstruction): a result summary, windowed page numbers, and Prev/Next. |
<simple-pagination> | Minimal Previous/Next only. |
Attributes
| Attribute | Description |
|---|---|
data | The PaginationData object (pass props.pagination). Required. |
on-each-side | (full only) Number of page links to show on each side of the current page. Omit/blank to show all page numbers. When a gap hides a single page, that page is shown instead of a one-page ellipsis. |
per-page | A literal page-size override read during static site generation (see per_page precedence). |
The route's index.ree typically switches on pagination_variant:
{#if props.pagination}
{#if props.pagination_variant === 'simple'}
<simple-pagination data="{= props.pagination }"></simple-pagination>
{:else}
<full-pagination data="{= props.pagination }"></full-pagination>
{/if}
{/if}
Template props
Each numbered page renders the route's index.ree with these extra props injected (alongside the usual page data and merged translations):
| Prop | Type | Description |
|---|---|---|
props.records | Record[] | The records for this page only (already sliced). |
props.pagination | PaginationData | The view-model - pass to a pagination component. |
props.pagination_variant | "full" | "simple" | The configured variant, for switching components. |
PaginationData
lib/pagination.ts builds this from a record count (not the records), the current page, the page size, and a url_for(page) callback:
| Field | Type | Description |
|---|---|---|
current_page | number | Current page (clamped to 1..last_page). |
last_page | number | Total number of pages (min 1). |
per_page | number | Items per page. |
total | number | Total records across all pages. |
from / to | number | 1-based index of the first/last item on this page (0 when empty). |
has_pages | boolean | Whether there is more than one page. |
on_first_page / on_last_page | boolean | Edge flags. |
prev_url / next_url | string | null | Previous/next page URL, or null at the ends. |
pages | PageLink[] | Every page (1..last_page) with { number, url, active }. The component windows this. |
show_when_single_page / always_show_prev_next | boolean | Behaviour flags carried for the (scope-isolated) component. |
labels | PaginationLabels | Localized UI strings (see below). |
Localized labels
Components are scope-isolated and cannot read the outer ui object, so every word is carried in PaginationData.labels. The script reads these from the ui.pagination translation block, falling back to English: previous, next, aria (the <nav> aria-label), showing, to, results, total_label.
The page-number window
build_window(pages, current, last, on_each_side) reduces the full page list to a window of ±on_each_side links around the current page, always keeping the first and last page and inserting an ellipsis marker where a run of pages is skipped. When on_each_side is null (the default - attribute omitted), every page is returned.
This algorithm is mirrored verbatim inside src/components/full-pagination.ree because the .ree engine has no module imports. The two must stay in sync; lib/pagination.test.ts is the source of truth for its behaviour.
How It Works
For each enabled route, the SSG script (and the dev server) does the following, once per language:
- Resolve the index template - the route needs a
<route>/index.ree; missing templates are skipped with a warning. - Resolve
per_page- literal attribute > route config > global default. - Collect records - via the markdown collector or the route's
load_records(lang). - Filter visibility - drafts and future-dated entries are dropped.
- Chunk records into pages of
per_page. - Build
PaginationDataper page (numbers, URLs, prev/next, window data). - Render
index.reewithprops.records(this page's slice),props.pagination, andprops.pagination_variant. - Write output as
/route/,/route/2/,/route/3/, …
Records are language-aware - each language collects and renders its own set independently.
Related
- Content Visibility - how drafts and future-dated entries are filtered out of listings.
- SSG Pipeline - where the pagination phase sits in the SSG script.
- Project Hooks - overriding visibility per-page via
content_visibility.