Pagination
Prev/next buttons bake their target offset in server-side, so a click just
sets $offset and re-fetches. The endpoint re-renders the whole
#pagination region — list, buttons (with correct disabled states), and
page info — with the same Astro component the page used for the initial
render. ?page= deep-links, and data-replace-url keeps it in sync.
Walks
- Canal towpath loopA 3 km route, roughly 45 minutes at an easy pace.
- Castle mound circuitA 10 km route, roughly 58 minutes at an easy pace.
- Old orchard trailA 8 km route, roughly 71 minutes at an easy pace.
- River meadow walkA 6 km route, roughly 84 minutes at an easy pace.
- Ironstone ridge pathA 4 km route, roughly 97 minutes at an easy pace.
The demo
---
/** Pagination demo. Deep link: ?page=… server-renders that page. */
import PaginatedEntries from '../PaginatedEntries.astro';
import { entries } from '../../data/entries';
const LIMIT = 5;
const maxOffset = (Math.ceil(entries.length / LIMIT) - 1) * LIMIT;
const page = Number(Astro.url.searchParams.get('page')) || 1;
const offset = Math.min(Math.max(0, (page - 1) * LIMIT), maxOffset);
---
<section
class="demo"
data-signals={`{offset: ${offset}}`}
data-replace-url={`'/examples/pagination' + ($offset ? '?page=' + ($offset / ${LIMIT} + 1) : '')`}
>
<h2>Walks</h2>
<PaginatedEntries offset={offset} />
</section>The shared component
---
/** Pagination region shared by the page and /api/pagination. */
import { entries } from '../data/entries';
interface Props {
offset: number;
limit?: number;
}
const { offset, limit = 5 } = Astro.props;
const total = entries.length;
const totalPages = Math.ceil(total / limit);
const page = Math.floor(offset / limit) + 1;
const items = entries.slice(offset, offset + limit);
const prevOffset = Math.max(0, offset - limit);
const nextOffset = offset + limit;
const hasPrev = offset > 0;
const hasNext = nextOffset < total;
---
<div id="pagination">
<ul class="entry-list">
{
items.map((entry) => (
<li>
<strong>{entry.title}</strong>
<span class="hint">{entry.blurb}</span>
</li>
))
}
</ul>
<nav class="pager" aria-label="Pagination">
<button
disabled={!hasPrev}
data-on:click={`$offset = ${prevOffset}; @get('/api/pagination')`}
>
« Previous
</button>
<p class="hint">Page {page} of {totalPages} · {total} results</p>
<button
disabled={!hasNext}
data-on:click={`$offset = ${nextOffset}; @get('/api/pagination')`}
>
Next »
</button>
</nav>
</div>The endpoint
import {
html,
readSignals,
SignalsValidationError,
} from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { z } from 'astro/zod';
import PaginatedEntries from '../../components/PaginatedEntries.astro';
export const prerender = false;
const signals = z.object({
offset: z.number().int().min(0).catch(0),
});
export const GET: APIRoute = async ({ request }) => {
let offset: number;
try {
({ offset } = await readSignals(request, signals));
} catch (err) {
if (err instanceof SignalsValidationError) return err.response();
throw err;
}
const container = await AstroContainer.create();
return html(
await container.renderToString(PaginatedEntries, { props: { offset } }),
);
};The plugin
import { attribute, effect } from '../engine';
/**
* `data-replace-url="<expression>"` keeps the address bar in sync with
* signal state: whenever signals the expression reads change, the current
* history entry is replaced with the evaluated URL (relative or absolute,
* same-origin only).
*
* ```html
* <div data-replace-url="'/businesses' + ($q ? '?q=' + $q : '')"></div>
* ```
*
* Upstream Datastar 1.0 moved its ReplaceUrl plugin to the paid Pro
* bundle; this is our own minimal equivalent with the same attribute name.
*/
attribute({
name: 'replace-url',
requirement: { key: 'denied', value: 'must' },
returnsValue: true,
apply({ rx }) {
return effect(() => {
const url = rx();
if (typeof url !== 'string' || url === '') return;
const resolved = new URL(url, window.location.origin);
if (resolved.origin !== window.location.origin) return;
history.replaceState(
history.state,
'',
resolved.pathname + resolved.search + resolved.hash,
);
});
},
});