Numbered pagination — ‹ Previous 1 2 3 … 12 Next › — for Instatic CMS loops.
Replaces the built-in pagination: 'infinite' "Load more" button with a real
pager whose links point at server-rendered pages the CMS already produces.
before [ 5 posts ] after [ 5 posts ]
( Load more ) ‹ Previous 1 2 3 … 12 Next ›
everything on /blog /blog · /blog?loop_…_page=2 · …
page 2 unreachable without JS page 2 is real HTML from the server
Instatic can already render any page of any loop. The publisher reads a
?loop_<loopNodeId>_page=N query parameter, and the public router deliberately
routes a request carrying it past the static-artefact fast path into a full
server render:
// server/publish/loopPrefetch.ts
function loopPageQueryKey(loopNodeId: string): string {
return `loop_${loopNodeId}_page`
}
const pageNumber = props.pagination === 'infinite' ? readPageNumber(ctx.url, node.id) : 1Nothing documents this, and nothing links to it. docs/features/loops.md
describes exactly two modes — 'none' and 'infinite' — and offers a manual
"one loop node per page" workaround for static navigation. The query parameter
is not mentioned anywhere in docs/.
So the capability is invisible: to readers, and to crawlers.
This plugin's entire job is to emit those links. It does not fetch listing content, splice HTML, or manage history. Clicking page 2 is an ordinary navigation to a URL the server renders from scratch.
Verified against a live Instatic site (conduit.wyre.ai, 2026-08-17):
$ curl -s "https://conduit.wyre.ai/blog" | grep -o 'href="/blog/[^"]*"' | sort -u
href="/blog/ai-for-msps-4-workflows-you-can-run-in-your-first-20-minutes"
href="/blog/shadow-ai-risks-for-msps-what-your-techs-are-actually-doing-right-now"
href="/blog/what-is-an-mcp-gateway-and-why-should-msps-care"
href="/blog/what-the-eu-ai-act-means-for-msps-and-what-you-actually-need-to-do"
href="/blog/2026-04-30-mcp-gateway-not-a-proxy"
$ curl -s "https://conduit.wyre.ai/blog?loop_Oz1G1FKV5FyxdlFDNKeZP_page=2" \
| grep -o 'href="/blog/[^"]*"' | sort -u
href="/blog/2026-04-30-mcp-gateway-not-a-proxy"
href="/blog/2026-04-30-msp-psa-automation-ai-stack" # ← only on page 2Different posts, server-rendered, no JavaScript involved.
-
Instatic at or near
6b055cf7(v0.0.16). The plugin reads only published markup and one existing endpoint, so it should tolerate drift, but that is the ref it was built and tested against. -
The loop must be set to
pagination: 'infinite'. This is not optional: the publisher only honours?loop_<id>_page=Nfor infinite-mode loops (loopPrefetch.ts), and the fragment endpoint used for counting returns400 Loop is not in infinite modefor anything else (server/handlers/cms/loop.ts).You keep the mode; the plugin replaces the button.
@instatic/plugin-sdk is not published to npm, and instatic-plugin is a
script inside the CMS repository rather than a global CLI. Building any
Instatic plugin therefore needs a local CMS checkout. bun run setup vendors
one into .instatic/ (gitignored, pinned to 6b055cf7).
$ git clone https://github.com/WYRE-AI/instatic-loop-pagination.git
$ cd instatic-loop-pagination
$ bun install
$ bun run setup # clones + installs the pinned Instatic checkout
$ bun run buildbuild writes dist/ and ../instatic-loop-pagination.plugin.zip — note the
CLI emits the zip beside the plugin directory, not inside it.
Upload that zip in the Instatic admin under Plugins, approve the single
frontend.assets permission, and enable it. Then republish the site: frontend
assets are spliced in at publish time, so existing static artefacts do not pick
the pager up until they are rebuilt.
Not verified end-to-end. Every build, lint, typecheck and test result below was produced locally, and the
?loop_…_page=Nbehaviour was confirmed against a live published site. The packaged plugin has not been installed into a running Instatic instance — treat the install flow as documented, not demonstrated.
Configuration lives on the injected <script> tag, declared in
instatic-plugin.config.ts. frontend.assets[] is a static manifest
declaration — there is no runtime channel from plugin settings into a published
page — so data-* attributes are the mechanism, and they stay inspectable on
the install consent screen. Edit and rebuild to change them.
| Attribute | Default | Meaning |
|---|---|---|
data-label |
Pagination |
Accessible name for the <nav> landmark. Set this per site. |
data-window |
2 |
Numbered pages shown either side of the current one. |
data-previous-label |
Previous |
Visible text for the previous control. |
data-next-label |
Next |
Visible text for the next control. |
data-loops |
(empty) | Comma-separated loop node ids to manage. Empty means every infinite-mode loop. |
data-max-pages |
200 |
Ceiling on page-count discovery, and the highest page number offered. |
data-cache-seconds |
300 |
How long a discovered page count is cached in sessionStorage. 0 disables. |
data-endpoint |
/_instatic/loop/ |
Base path of the fragment endpoint, used only for counting. |
Every value falls back to its default when malformed or out of range, so a typo degrades rather than breaks.
The stylesheet is deliberately unopinionated: sizes in em, colours from
currentColor, so the pager inherits its surroundings. Override the custom
properties from your own site CSS, which loads after the injected block:
.instatic-pager {
--instatic-pager-size: 2.75em;
--instatic-pager-radius: 999px;
--instatic-pager-current: #2563eb;
}Results-per-page is the loop's own pageSize prop, edited in the CMS
Properties Panel on the loop node. Set it to 5, 10, 25 — whatever you want.
The plugin deliberately does not offer its own page-size override. The
server slices by pageSize when it renders ?loop_…_page=N; a client-side
"page size" would be a claim the server does not honour, producing pages that
disagree with their own URLs. The honest knob is the one the server reads.
Changing pageSize and republishing repaginates everything, and the pager
follows automatically — it derives page numbers from the server's own answers,
never from a number of its own.
- Find. On every published page (injection is site-wide), one
querySelectorAllfor[data-instatic-loop][data-instatic-loop-mode="infinite"]. No match — which is most pages — costs nothing. - Take over. Set
data-instatic-pager-managed, flipdata-instatic-loop-has-moretofalseso the host runtime returns early, and remove any Load-more button already attached. AMutationObservercatches one attached later, because script order between the host's injected runtime and this one is not guaranteed. - Paint. Render
‹ Previous 1 Next ›immediately from what the markup already states. - Count. Work out the number of pages (below), then repaint the full run.
Instatic tells the browser whether there is a next page. It never says how many pages exist — not in the markup, not in the endpoint:
$ curl -s "https://conduit.wyre.ai/_instatic/loop/Oz1G1FKV5FyxdlFDNKeZP?page=2" \
| python3 -c "import sys,json;print(sorted(json.load(sys.stdin).keys()))"
['hasMore', 'html', 'pageNumber']hasMore(n) is monotone — true before the last page, false from it onward — so
the total is the smallest n where it flips. An exponential probe followed by
a binary search finds that in O(log n) requests rather than the O(n) a walk
would cost, against the existing fragment endpoint.
The search is seeded with everything the current render already proves. If the
server rendered page N with items in it, then every page before N is full, and
page N's own hasMore is in the markup. In practice:
| Situation | Requests |
|---|---|
| Reader on the last page | 0 |
| Two-page blog, reader on page 1 | 1 |
| 50-page archive, cold | ~11 |
Any page, warm sessionStorage |
0 |
Host emits data-instatic-loop-total (see upstream/) |
0 |
If discovery fails — offline, endpoint error — the provisional
‹ Previous 1 Next › strip stays. It is still a working pager; the reader just
does not get the full number run.
This was the deciding factor in the design, so here it is plainly.
What you get
- Page 2..N are real, distinct URLs with real, distinct, server-rendered
content. A crawler that never executes JavaScript still gets correct HTML
from
?loop_…_page=2. This is the part that made the approach worth building; a client-side pager that leaves every page on one URL would have been worse than the Load-more button it replaces. - Real
<a href>links. Crawlable, middle-clickable, shareable, bookmarkable. The back button works because navigation is ordinary navigation — there is nopushStatein this codebase. - Page 1 stays canonical. The pager links to the bare
/blog, never?loop_…_page=1, so no URL has a byte-identical twin. - Unrelated query parameters are preserved and canonicalise away in the host's render cache, so UTM tags do not fragment the cache.
What you don't
-
The links only exist once JavaScript runs. The pager markup is injected by the runtime. Googlebot renders JavaScript and will follow them; a non-rendering crawler will not discover page 2 unless you link it some other way (a sitemap entry, a footer link). This is the real cost of the approach, and it is the one thing an upstream
base.paginationmodule — which Instatic's own source says is the intended home for numeric pagination — would fix properly:src/modules/base/loop/index.tsNumeric pagination is intentionally NOT a mode here — it will live in a separatebase.paginationmodule that pairs with a loop by ID. -
Every page shares one
<title>and<meta description>. They are properties of the page node, and all pages are the same node. Verified live:?loop_…_page=2returns<title>Blog | Conduit by WYRE AI</title>, identical to page 1.frontend.assetsis site-wide with no per-page context, so the plugin cannot vary them. Per-page metadata needs thepublish.htmlfilter and thecms.hookspermission — a different, more privileged plugin. -
No
rel=canonicalmanagement. Same reason. (Google deprecatedrel=next/rel=prevfor indexing in 2019; the pager still emits them on the step links, where they remain valid HTML and are used by some other agents.) -
Page 2+ is a dynamic render, not a static file. Requests carrying a
loop_*_pageparameter are excluded from the disk fast path by design and served from the host's per-publish-version LRU instead. Correct, slightly more expensive than page 1, and unchanged by this plugin — it is how the parameter already worked.
A client-side pager that swaps content in place. The obvious design, and
the wrong one: it leaves every page on /blog, so page 2 is invisible to
crawlers and unlinkable by readers. That is strictly worse for SEO than the
Load-more button it would replace. Abandoned once ?loop_…_page=N turned out
to render server-side.
Separate loop nodes with an offset filter — the workaround
docs/features/loops.md documents. Genuinely the best SEO story available
(real static pages, zero JavaScript, works under script-src 'none'), and
genuinely unworkable as a living blog: it needs one page node per page of
results, hand-written links between them, and a human to notice when post 26
silently falls off the end. It remains the right answer for a frozen archive.
For a growing listing it is not — and once the host renders ?loop_…_page=N
itself, it is also unnecessary.
Generating those page nodes automatically from a plugin. Technically
possible: Instatic stores pages as rows in a pages content table, and the
plugin content API has no system-table guard, so cms.content.write plus
contentAccess: [{ table: 'pages' }] would let a plugin create them. That
trades one permission for write access to every page on the site, needs a
regeneration trigger nobody owns, and produces content that duplicates what the
server already renders on demand. Not worth it.
A publish.html filter injecting the pager server-side. Would remove the
JavaScript dependency entirely and is the only route to per-page <title> and
rel=canonical. But the filter receives HTML and page identity — not
totalItems — so it could emit Previous/Next but not numbers, which is the
requirement. It also costs the cms.hooks permission, which carries a great
deal more than pagination. A worthwhile follow-up; not the first version.
Upstream changes as a prerequisite. Not needed. The one upstream change
that would genuinely help is small, optional, and shipped as a patch in
upstream/ — the plugin works without it and gets faster with it.
One: frontend.assets.
No cms.hooks. No cms.routes. No cms.content.*. No network.outbound. No
server entrypoint, no editor entrypoint, no module pack. The built manifest:
{
"id": "wyre.loop-pagination",
"permissions": ["frontend.assets"],
"entrypoints": {}
}Instatic derives each page's Content-Security-Policy from what the page
actually contains. Declaring an external script relaxes script-src to
'self' — and 'self' is the only value frontend.assets can ever
produce; the directive is set from a hardcoded literal in
server/publish/frontendInjections.ts. That is why the runtime is bundled into
the plugin zip and served same-origin from /uploads/plugins/…. A CDN-hosted
script could not execute even if the plugin wanted one.
The inline stylesheet adds nothing: Instatic's base policy already emits
style-src 'self' 'unsafe-inline' on every page, because the publisher writes
authored styles as inline style= attributes.
On a site whose blog listing already uses pagination: 'infinite', script-src 'self' is present before you install anything — the host injects its own
loop-runtime script and lifts the directive to do it:
$ curl -s https://conduit.wyre.ai/blog | grep -o 'script-src[^;]*'
script-src 'self'Injection is site-wide. collectFrontendInjections() takes no page
identity, so the script and stylesheet land on every published page, and
script-src is lifted to 'self' on every published page — including ones
that previously had script-src 'none'. The runtime no-ops on pages with no
matching loop, but the CSP change is real and site-wide. Weigh it before
installing.
The assets also appear in the editor preview. frontend.assets injection
runs in the runtime preview path; publish.before/publish.html/publish.after
do not. So the pager is visible when previewing a page, which is convenient,
and is a difference worth knowing if you are debugging.
-
Requires JavaScript to see the pager. The pages themselves are server-rendered and work without it; the links to them are not. See SEO.
-
Only manages
pagination: 'infinite'loops. Static ('none') loops have no server-side page parameter to link to. -
Out-of-range page numbers dead-end. Instatic's
renderLoop()returns an empty string for a loop with no items, so the wrapper element itself disappears — the plugin finds nothing to attach to, and there is no pager offering a way back. Verified live on a 2-page listing:$ curl -s "https://conduit.wyre.ai/blog?loop_Oz1G1FKV5FyxdlFDNKeZP_page=99" \ | grep -c 'data-instatic-loop=' 0
The page still returns 200 with full site chrome, just no listing. Links the plugin generates never point out of range; this only affects hand-typed or stale URLs.
-
The cached page count can be up to
data-cache-secondsstale after publishing new posts. There is no publish-version token in the published markup to invalidate against. Worst case is one wrong trailing page number for five minutes; the links still work. Setdata-cache-seconds="0"to opt out. -
data-max-pagesis a hard ceiling. A listing longer than it reports exactly that many pages. Raise it if you have a very long archive; each doubling costs roughly one extra request during discovery. -
Multiple managed loops on one page each get their own parameter and paginate independently — that is the host's design, not an invention here — but each one runs its own discovery.
-
The bundle is ~14 KB unminified, shipped site-wide. The build CLI does not minify; the source is deliberately readable.
$ bun install
$ bun run setup # vendor the pinned Instatic checkout (once)
$ bun run test # unit tests (scoped to tests/)
$ bun run typecheck # tsc, filtered to this repo's own diagnostics
$ bun run lint # instatic-plugin lint
$ bun run build # dist/ + ../instatic-loop-pagination.plugin.zipCI runs all of these on a clean runner from a fresh clone, so the documented
install steps are exercised on every push. Local output on 6b055cf7,
2026-08-17:
$ bun run test
115 pass
0 fail
310 expect() calls
Ran 115 tests across 9 files. [92.00ms]
$ bun run typecheck
✓ No type errors in plugin sources.
$ bun run lint
✓ wyre.loop-pagination: no issues found
$ bun run build
✓ Built wyre.loop-paginationfrontend/pager.ts browser entry — bundled to dist/frontend/pager.js
frontend/lib/
pageModel.ts pure: windowing and ellipsis rules
markup.ts pure: accessible HTML
urls.ts pure: the host's loop_<id>_page contract
discovery.ts pure: O(log n) page counting over an injected probe
config.ts pure: data-* parsing
cache.ts sessionStorage, guarded
probe.ts the one network call
dom.ts the host markup contract
controller.ts per-loop orchestration
styles/pager.css inlined into the manifest at build time
upstream/ optional CMS patch + its test
Everything except controller.ts, dom.ts and the entry point is pure and
tested without a DOM. controller.ts takes its clock, storage, network and
document as injected dependencies, so the whole flow — provisional paint,
discovery, repaint, cache write, failure — is driven deterministically in
tests/controller.test.ts.
tsconfig.json maps @instatic/plugin-sdk into the vendored checkout, which
drags the host's source into the same TypeScript program. The host compiles
under looser options, so scripts/typecheck.ts runs tsc and fails only on
diagnostics from files this repo owns — rather than weakening this repo's
strict settings to silence a dependency.
document.currentScript is null inside an ES module. Instatic's own
loopRuntime.ts reads its data-instatic-loop-endpoint attribute that way
while being injected as <script type="module">, so that attribute is silently
never read — harmless there only because the fallback equals the intended
value. This plugin finds its own tag by a marker attribute instead, so
configuration works regardless of injection strategy.
MIT — see LICENSE.
Instatic is MIT-licensed and self-hosted. This plugin is not affiliated with its authors.