Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 7e9d0ea09d7fd1dfcf481f3a5319b2f4f294a0e0)
+++ src/routes/activitypub.js	(revision bac4bf3573c99662c112c44d1b1519f1159d5762)
@@ -28,4 +28,17 @@
 
 const router = express.Router();
+
+/**
+ * Welke pagina vraagt de lezer? (shaer-sk4)
+ *
+ * Hier stond `!!req.query.page` -- of de parameter er STAAT, niet welke. Daardoor
+ * gaf ?page=2 en ?page=99 allemaal pagina 1, en noemde het antwoord zichzelf ook
+ * nog pagina 1. Onleesbaar getal of geen parameter: dan de wortel.
+ */
+function paginaNr(req) {
+  if (req.query.page === undefined) return false;
+  const n = Math.floor(Number(req.query.page));
+  return Number.isFinite(n) && n > 0 ? n : 1;
+}
 // The whole fediverse layer can be turned off (solo "no federation" mode):
 // then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
@@ -182,5 +195,5 @@
   });
   if (audience === 'blocked') {
-    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: !!req.query.page }), 'private, no-store');
+    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: paginaNr(req) }), 'private, no-store');
   }
   const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
@@ -192,5 +205,5 @@
   // De tracks gaan mee voor iedereen die de deur door mag; de blocked-tak
   // hierboven levert bewust een outbox ZONDER posts en zonder tracks.
-  const ob = AP.buildOutbox(baseUrl(req), site, posts, AP.siteOpenTracks(site.id), { page: !!req.query.page });
+  const ob = AP.buildOutbox(baseUrl(req), site, posts, AP.siteOpenTracks(site.id), { page: paginaNr(req) });
   if (audience === 'friend') {
     // The owner's app builds its feed from this leg, and every note here is
@@ -986,8 +999,8 @@
     // Default = bare references; enrich only when the client asks (FEP-9876).
     const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
-    return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
+    return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
   }
   const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
-  AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
+  AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n, null, { page: paginaNr(req) }));
 });
 
@@ -1005,9 +1018,9 @@
       items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
     } catch { /* table may not exist */ }
-    return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
+    return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
   }
   let n = 0;
   try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
-  AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
+  AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n, null, { page: paginaNr(req) }));
 });
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 7e9d0ea09d7fd1dfcf481f3a5319b2f4f294a0e0)
+++ src/services/ActivityPubService.js	(revision bac4bf3573c99662c112c44d1b1519f1159d5762)
@@ -1032,4 +1032,11 @@
     .sort((a, b) => wanneer(b) - wanneer(a))
     .slice(0, MAX_OUTBOX);
+  // WAT HIER NOG NIET GEPAGINEERD IS, en dat hoort genoemd (shaer-sk4): deze
+  // lijst is al door de route op twintig rijen afgekapt, dus pagina 2 is leeg.
+  // Echt doorbladeren vraagt een LIMIT/OFFSET in SQL -- en dat is hier lastiger
+  // dan bij volgers, want posts en tracks worden op DATUM door elkaar gevlochten
+  // en komen uit twee tabellen. Dat vraagt een UNION met een offset erover, geen
+  // tweede slice. De vorm klopt nu wel: pagina 2 zegt eerlijk dat hij leeg is en
+  // biedt geen `next` aan, in plaats van pagina 1 nog eens te geven.
   // GEPAGINEERD, ook al past alles op een pagina (Funkwhale, 11-8).
   //
@@ -1049,16 +1056,16 @@
 // account owner (a C2S bearer scoped to this site) gets the real actor URIs via
 // `items`, so their own client can build a friends list.
-export function buildFollowers(base, site, count, items = null) {
+export function buildFollowers(base, site, count, items = null, { page = false } = {}) {
   const id = `${actorId(base, site.slug)}/followers`;
   // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0) });
+  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
 }
 
 // The accounts this site follows — count only, mirroring buildFollowers. The spec lists
 // `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
-export function buildFollowing(base, site, count, items = null) {
+export function buildFollowing(base, site, count, items = null, { page = false } = {}) {
   const id = `${actorId(base, site.slug)}/following`;
   // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0) });
+  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
 }
 
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 7e9d0ea09d7fd1dfcf481f3a5319b2f4f294a0e0)
+++ src/services/ap-core.js	(revision bac4bf3573c99662c112c44d1b1519f1159d5762)
@@ -212,16 +212,43 @@
  *   extra       velden die op de wortel horen (attributedTo, shaer:*)
  */
-export function pagedCollection(id, items, { totalItems, page = false, extra = {} } = {}) {
+/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
+export const PAGINA_GROOTTE = 20;
+
+/**
+ * Een collectie, met ECHTE paginering (shaer-sk4).
+ *
+ * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
+ * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
+ * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
+ * pagina's identiek bleven; dit is waarom.
+ *
+ * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
+ * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
+ * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
+ * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
+ *
+ * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
+ * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
+ * antwoord zijn dat over zichzelf liegt.
+ */
+export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, extra = {} } = {}) {
   const lijst = items || [];
   const telling = totalItems === undefined ? lijst.length : totalItems;
-  const eerste = `${id}?page=1`;
+  const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
+  const paginas = Math.max(1, Math.ceil(lijst.length / grootte));
+  const url = (n) => `${id}?page=${n}`;
+
   if (page) {
+    const n = Math.max(1, Math.floor(Number(page)) || 1);
+    const deel = lijst.slice((n - 1) * grootte, n * grootte);
     return {
       '@context': AP_CONTEXT,
-      id: eerste,
+      id: url(n),
       type: 'OrderedCollectionPage',
       partOf: id,
       totalItems: telling,
-      orderedItems: lijst,
+      ...(n > 1 ? { prev: url(n - 1) } : {}),
+      ...(n < paginas ? { next: url(n + 1) } : {}),
+      orderedItems: deel,
     };
   }
@@ -232,6 +259,6 @@
     ...extra,
     totalItems: telling,
-    first: eerste,
-    last: eerste,
+    first: url(1),
+    last: url(paginas),
     orderedItems: lijst,
   };
Index: test/collectie-paginering.test.js
===================================================================
--- test/collectie-paginering.test.js	(revision bac4bf3573c99662c112c44d1b1519f1159d5762)
+++ test/collectie-paginering.test.js	(revision bac4bf3573c99662c112c44d1b1519f1159d5762)
@@ -0,0 +1,75 @@
+// Echte paginering (shaer-sk4).
+//
+// Robin zag dat elke ?page= dezelfde inhoud gaf. Dat klopte: pagedCollection
+// veranderde alleen de VORM en sneed nooit, first en last wezen allebei naar
+// pagina 1, en pagina 99 noemde zichzelf pagina 1.
+//
+// De WORTEL houdt zijn items inline, en dat is de hele reden dat dit veilig is:
+// Shaer leest een document en volgt `next` niet. Werd de wortel nu leeg, dan
+// kreeg elke draaiende app nul items en geen foutmelding.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+
+const { pagedCollection, PAGINA_GROOTTE } = await import('../src/services/ap-core.js');
+
+const ID = 'https://ons.test/ap/users/dev/followers';
+const vijftig = Array.from({ length: 50 }, (_, i) => `https://elders.test/u/${i}`);
+
+test('de wortel draagt ALLES inline, en wijst naar echte grenzen', () => {
+  const c = pagedCollection(ID, vijftig);
+  assert.equal(c.type, 'OrderedCollection');
+  assert.equal(c.orderedItems.length, 50, 'inline, want de app volgt next niet');
+  assert.equal(c.first, `${ID}?page=1`);
+  assert.equal(c.last, `${ID}?page=3`, '50 items bij 20 per pagina is drie paginas');
+});
+
+test('pagina 1 snijdt echt, en biedt een next', () => {
+  const p = pagedCollection(ID, vijftig, { page: 1 });
+  assert.equal(p.type, 'OrderedCollectionPage');
+  assert.equal(p.id, `${ID}?page=1`);
+  assert.equal(p.partOf, ID);
+  assert.equal(p.orderedItems.length, PAGINA_GROOTTE);
+  assert.equal(p.orderedItems[0], vijftig[0]);
+  assert.equal(p.next, `${ID}?page=2`);
+  assert.equal(p.prev, undefined, 'de eerste heeft geen vorige');
+});
+
+test('pagina 2 geeft ANDERE items dan pagina 1 -- dat was de klacht', () => {
+  const een = pagedCollection(ID, vijftig, { page: 1 }).orderedItems;
+  const twee = pagedCollection(ID, vijftig, { page: 2 }).orderedItems;
+  assert.notDeepEqual(een, twee);
+  assert.equal(twee[0], vijftig[20]);
+  assert.equal(pagedCollection(ID, vijftig, { page: 2 }).prev, `${ID}?page=1`);
+});
+
+test('de laatste pagina heeft de rest en GEEN next', () => {
+  const p = pagedCollection(ID, vijftig, { page: 3 });
+  assert.equal(p.orderedItems.length, 10);
+  assert.equal(p.next, undefined);
+  assert.equal(p.prev, `${ID}?page=2`);
+});
+
+test('een pagina voorbij het einde is leeg en zegt dat ook', () => {
+  // Hem naar de laatste terugbuigen zou opnieuw een antwoord zijn dat over
+  // zichzelf liegt -- precies wat er mis was.
+  const p = pagedCollection(ID, vijftig, { page: 99 });
+  assert.equal(p.id, `${ID}?page=99`, 'en niet ?page=1');
+  assert.equal(p.orderedItems.length, 0);
+  assert.equal(p.next, undefined);
+});
+
+test('een lege collectie heeft een pagina, geen nul', () => {
+  const c = pagedCollection(ID, []);
+  assert.equal(c.first, `${ID}?page=1`);
+  assert.equal(c.last, `${ID}?page=1`);
+});
+
+test('totalItems telt het GEHEEL, ook op een pagina', () => {
+  // De count-only vorm voor het publiek geeft een telling zonder items; die
+  // mag een pagina niet stilletjes tot nul maken.
+  const p = pagedCollection(ID, vijftig, { page: 2, totalItems: 12345 });
+  assert.equal(p.totalItems, 12345);
+});
