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:

  1. Config (config/pagination.ts) - which routes paginate and how.
  2. Records - where the items come from. Markdown files by default (lib/collect_records.ts), or a custom load_records(lang) loader for external sources.
  3. 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

OptionTypeDefaultDescription
enabledbooleantrueGlobal master switch. false builds no pagination pages at all.
per_pagenumber10Default items per page. Routes can override via per_page.
path_segmentstring""URL segment before the page number. ""/blog/2/; "page"/blog/page/2/ (the word is not localized).
show_when_single_pagebooleanfalseRender the pagination element even when all results fit on page 1. Matches Laravel's hasPages().
always_show_prev_nextbooleantrueAlways render Previous/Next, disabled at the first/last page.
variant"full" | "simple""full"Which shipped component the route's index template uses.
routesPaginationRoute[][]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-side attribute. See Components.

PaginationRoute

FieldTypeDefaultDescription
routestring-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"autoWhere 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_pagenumberglobal per_pagePer-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):

  1. A literal per-page="N" attribute on the pagination component in the route's index.ree source.
  2. The route's per_page in config/pagination.ts.
  3. 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):

Pagepath_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 named 2) 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:

FieldTypeDescription
rel_pathstringSource path relative to the public dir.
canonical_pathstringLanguage-agnostic URL path (e.g. /blog/my-post).
titlestringFrontmatter title, else first # Heading, else the slug.
descriptionstringdescription/excerpt/summary/abstract, else first paragraph; truncated to 320 chars.
content_htmlstringRendered markdown body HTML.
published_atDatepublished_at/date, else file mtime.
authorsAuthor[]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>
ComponentDescription
<full-pagination>Numbered paginator (Laravel Tailwind paginator reconstruction): a result summary, windowed page numbers, and Prev/Next.
<simple-pagination>Minimal Previous/Next only.

Attributes

AttributeDescription
dataThe 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-pageA 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):

PropTypeDescription
props.recordsRecord[]The records for this page only (already sliced).
props.paginationPaginationDataThe 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:

FieldTypeDescription
current_pagenumberCurrent page (clamped to 1..last_page).
last_pagenumberTotal number of pages (min 1).
per_pagenumberItems per page.
totalnumberTotal records across all pages.
from / tonumber1-based index of the first/last item on this page (0 when empty).
has_pagesbooleanWhether there is more than one page.
on_first_page / on_last_pagebooleanEdge flags.
prev_url / next_urlstring | nullPrevious/next page URL, or null at the ends.
pagesPageLink[]Every page (1..last_page) with { number, url, active }. The component windows this.
show_when_single_page / always_show_prev_nextbooleanBehaviour flags carried for the (scope-isolated) component.
labelsPaginationLabelsLocalized 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:

  1. Resolve the index template - the route needs a <route>/index.ree; missing templates are skipped with a warning.
  2. Resolve per_page - literal attribute > route config > global default.
  3. Collect records - via the markdown collector or the route's load_records(lang).
  4. Filter visibility - drafts and future-dated entries are dropped.
  5. Chunk records into pages of per_page.
  6. Build PaginationData per page (numbers, URLs, prev/next, window data).
  7. Render index.ree with props.records (this page's slice), props.pagination, and props.pagination_variant.
  8. Write output as /route/, /route/2/, /route/3/, …

Records are language-aware - each language collects and renders its own set independently.

  • 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.