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

The demo

src/components/demos/PaginationDemo.astro
---
/** 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

src/components/PaginatedEntries.astro
---
/** 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

src/pages/api/pagination.ts
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

packages/astro-datastar/src/plugins/replace-url.ts
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,
      );
    });
  },
});