Skip to content

Repository files navigation

instatic-loop-pagination

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

The short version

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) : 1

Nothing 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 2

Different posts, server-rendered, no JavaScript involved.


Requirements

  • 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=N for infinite-mode loops (loopPrefetch.ts), and the fragment endpoint used for counting returns 400 Loop is not in infinite mode for anything else (server/handlers/cms/loop.ts).

    You keep the mode; the plugin replaces the button.


Install

@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 build

build 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=N behaviour 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

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.

Styling

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

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.


How it works

  1. Find. On every published page (injection is site-wide), one querySelectorAll for [data-instatic-loop][data-instatic-loop-mode="infinite"]. No match — which is most pages — costs nothing.
  2. Take over. Set data-instatic-pager-managed, flip data-instatic-loop-has-more to false so the host runtime returns early, and remove any Load-more button already attached. A MutationObserver catches one attached later, because script order between the host's injected runtime and this one is not guaranteed.
  3. Paint. Render ‹ Previous 1 Next › immediately from what the markup already states.
  4. Count. Work out the number of pages (below), then repaint the full run.

Page counting

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.


SEO: what you get, and what you don't

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 no pushState in 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.pagination module — which Instatic's own source says is the intended home for numeric pagination — would fix properly:

    src/modules/base/loop/index.ts Numeric pagination is intentionally NOT a mode here — it will live in a separate base.pagination module 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=2 returns <title>Blog | Conduit by WYRE AI</title>, identical to page 1. frontend.assets is site-wide with no per-page context, so the plugin cannot vary them. Per-page metadata needs the publish.html filter and the cms.hooks permission — a different, more privileged plugin.

  • No rel=canonical management. Same reason. (Google deprecated rel=next/rel=prev for 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_*_page parameter 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.


What was considered and rejected

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.


Permissions

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": {}
}

What that permission does to your CSP

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.


Limitations

  • 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-seconds stale 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. Set data-cache-seconds="0" to opt out.

  • data-max-pages is 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.


Development

$ 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.zip

CI 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-pagination

Layout

frontend/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.

A trap worth knowing

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.


License

MIT — see LICENSE.

Instatic is MIT-licensed and self-hosted. This plugin is not affiliated with its authors.

About

Numbered pagination for Instatic CMS loops — real page-number links to the server-rendered pages the CMS already produces.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages