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,
   };
