Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 783b9ffe18b02634e4194b7f4b79673908e0a7a2)
+++ src/routes/activitypub.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -197,13 +197,19 @@
     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)";
-  const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, quote_json, embed_json, published_at, created_at
-     FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
-     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
-  ).all(site.id);
+  // ECHT DOORBLADEREN (shaer-sk4). Hier stonden twintig posts uit SQL met een
+  // tweede kap van twintig eroverheen: alles daarvoor was niet op een volgende
+  // pagina maar helemaal onbereikbaar. outboxSlice pagineert over de UNION van
+  // posts en tracks, want die vlechten op datum en zijn met twee losse queries
+  // niet te offsetten.
+  //
   // 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: paginaNr(req) });
+  const nr = paginaNr(req);
+  const { posts, tracks, totaal } = AP.outboxSlice(site.id, {
+    fanOnly: audience === 'friend',
+    offset: (Math.max(1, nr || 1) - 1) * AP.PAGINA_GROOTTE,
+    limit: AP.PAGINA_GROOTTE,
+  });
+  const ob = AP.buildOutbox(baseUrl(req), site, posts, tracks, { page: nr, totalItems: totaal, alGesneden: true });
   if (audience === 'friend') {
     // The owner's app builds its feed from this leg, and every note here is
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 783b9ffe18b02634e4194b7f4b79673908e0a7a2)
+++ src/services/ActivityPubService.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -29,10 +29,10 @@
 import Blocklist from './BlocklistService.js';
 import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection } from './ap-core.js';
+import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE } from './ap-core.js';
 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
 export { AP_CONTEXT, actorId, noteId, guessMediaType };
 // De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
 // vandaan kwam, zodat elke bestaande aanroep blijft werken.
-import {
+import { TRACK_KOLOMMEN,
   playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
   buildTrackAudio, buildTrackCollection, buildTrackCreate,
@@ -1019,5 +1019,48 @@
  * zijn eigen database bevraagt zou dwars door die deur heen leveren.
  */
-export function buildOutbox(base, site, posts, tracks = [], { page = false } = {}) {
+/**
+ * Een PAGINA van de outbox, in SQL (shaer-sk4).
+ *
+ * De outbox mengt twee bronnen: posts en open tracks, gevlochten op datum. Een
+ * offset over die twee kan niet met twee losse queries -- je weet niet hoeveel
+ * van elk er in pagina drie horen. Vandaar een UNION met de datum als sleutel,
+ * daar de LIMIT/OFFSET overheen, en pas dan de rijen zelf ophalen.
+ *
+ * Wat er stond was geen paginering maar een KAP: de route haalde twintig posts
+ * en hield daarvan twintig items over. Alles daarvoor was niet op een volgende
+ * pagina maar helemaal onbereikbaar.
+ *
+ * @param {boolean} fanOnly  mag de lezer ook de fans-only posts zien?
+ */
+export function outboxSlice(siteId, { fanOnly = false, offset = 0, limit = MAX_OUTBOX } = {}) {
+  const fanClause = fanOnly ? '' : 'AND (p.fan_only IS NULL OR p.fan_only = 0)';
+  const unie = `
+    SELECT 'post' AS soort, p.id AS id, COALESCE(p.published_at, p.created_at) AS wanneer
+      FROM posts p WHERE p.site_id = ? AND p.status = 'published' ${fanClause}
+    UNION ALL
+    SELECT 'track', t.id, t.created_at
+      FROM audio_tracks t WHERE t.site_id = ? AND t.fedi_open = 1`;
+  let rijen = [], totaal = 0;
+  try {
+    totaal = db.prepare(`SELECT COUNT(*) n FROM (${unie})`).get(siteId, siteId).n;
+    rijen = db.prepare(`SELECT soort, id FROM (${unie}) ORDER BY wanneer DESC LIMIT ? OFFSET ?`)
+      .all(siteId, siteId, limit, Math.max(0, offset));
+  } catch { return { posts: [], tracks: [], totaal: 0 }; }
+
+  const postIds = rijen.filter((r) => r.soort === 'post').map((r) => r.id);
+  const trackIds = rijen.filter((r) => r.soort === 'track').map((r) => r.id);
+  const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
+  const posts = postIds.length ? db.prepare(
+    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning,
+            c2s_attachments, quote_json, embed_json, published_at, created_at
+       FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
+  const tracks = trackIds.length ? db.prepare(
+    `SELECT ${TRACK_KOLOMMEN}
+       FROM audio_tracks t JOIN media m ON m.id = t.media_id
+      WHERE t.id IN (${gaten(trackIds.length)})`).all(...trackIds) : [];
+  return { posts, tracks, totaal };
+}
+
+export function buildOutbox(base, site, posts, tracks = [], { page = false, totalItems, alGesneden = false } = {}) {
   const id = `${actorId(base, site.slug)}/outbox`;
   const wanneer = (x) => Date.parse(x && x.published ? x.published : 0) || 0;
@@ -1031,5 +1074,5 @@
   ]
     .sort((a, b) => wanneer(b) - wanneer(a))
-    .slice(0, MAX_OUTBOX);
+    .slice(0, alGesneden ? Infinity : 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.
@@ -1050,5 +1093,5 @@
   // wie hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Er is
   // precies een pagina, dus first en last wijzen naar dezelfde.
-  return pagedCollection(id, items, { page });
+  return pagedCollection(id, items, { page, totalItems, alGesneden });
 }
 
@@ -6528,5 +6571,5 @@
 export default {
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
-  deriveHandle, localSlugOf,
+  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   channelUrls, channelCategory, timelineFields, guessMediaType,
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 783b9ffe18b02634e4194b7f4b79673908e0a7a2)
+++ src/services/ap-core.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -232,14 +232,18 @@
  * antwoord zijn dat over zichzelf liegt.
  */
-export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, extra = {} } = {}) {
+export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, extra = {} } = {}) {
   const lijst = items || [];
   const telling = totalItems === undefined ? lijst.length : totalItems;
   const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
-  const paginas = Math.max(1, Math.ceil(lijst.length / grootte));
+  // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
+  // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
+  // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
+  // en nooit een `next` aanbieden.
+  const paginas = Math.max(1, Math.ceil((alGesneden ? telling : 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);
+    const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
     return {
       '@context': AP_CONTEXT,
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 783b9ffe18b02634e4194b7f4b79673908e0a7a2)
+++ src/services/music/index.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -18,5 +18,5 @@
 
 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
-const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
+export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
      t.position, t.license,
      m.filename, m.storage_path, m.mime_type, m.size`;
