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`;
Index: test/outbox-diepte.test.js
===================================================================
--- test/outbox-diepte.test.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
+++ test/outbox-diepte.test.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -0,0 +1,88 @@
+// De outbox pagineert nu ECHT door (shaer-sk4).
+//
+// Er stond geen paginering maar een KAP: de route haalde twintig posts uit SQL
+// en hield daar twintig items van over. Alles daarvoor was niet op een volgende
+// pagina maar helemaal onbereikbaar.
+//
+// Posts en tracks vlechten op datum, dus een offset over die twee kan niet met
+// twee losse queries -- je weet niet hoeveel van elk er in pagina drie horen.
+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 dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'dev', 'Dev', 'u1');
+
+// 45 posts en 5 open tracks, met dagen ertussen zodat de volgorde vaststaat.
+const dag = (n) => new Date(Date.UTC(2026, 0, 1, 12) + (n - 1) * 86400000).toISOString();
+for (let i = 1; i <= 45; i++) {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES (?,?,?,?,?,?,'published',?)`)
+    .run(`p${i}`, 's1', 'u1', `post-${i}`, `Post ${i}`, '<p>x</p>', dag(i));
+}
+const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1000)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,1,?)');
+for (let i = 1; i <= 5; i++) {
+  insM.run(`m${i}`, 's1', `t${i}.mp3`, `audio/t${i}.mp3`, 'audio/mpeg');
+  insT.run(`t${i}`, 's1', `Track ${i}`, 100, `m${i}`, dag(20 + i));
+}
+
+test('de telling is het GEHEEL, niet de pagina', () => {
+  const { totaal } = AP.outboxSlice('s1', { offset: 0, limit: 20 });
+  assert.equal(totaal, 50, '45 posts + 5 tracks');
+});
+
+test('een pagina haalt uit BEIDE bronnen, gevlochten op datum', () => {
+  // Pagina 2 valt precies over de tracks van dag 21..25 heen; met twee losse
+  // queries en een offset per tabel zou dat niet kloppen.
+  const p2 = AP.outboxSlice('s1', { offset: 20, limit: 20 });
+  assert.equal(p2.posts.length + p2.tracks.length, 20);
+  assert.ok(p2.tracks.length > 0, 'de tracks zitten midden in de reeks');
+  assert.ok(p2.posts.length > 0);
+});
+
+test('opeenvolgende paginas overlappen niet en laten niets vallen', () => {
+  const zie = new Set();
+  let som = 0;
+  for (let n = 1; n <= 3; n++) {
+    const s = AP.outboxSlice('s1', { offset: (n - 1) * 20, limit: 20 });
+    for (const p of s.posts) zie.add('p:' + p.id);
+    for (const t of s.tracks) zie.add('t:' + t.id);
+    som += s.posts.length + s.tracks.length;
+  }
+  assert.equal(som, 50, 'drie paginas dekken alles');
+  assert.equal(zie.size, 50, 'en niets twee keer');
+});
+
+test('de laatste pagina is korter en de pagina erna is leeg', () => {
+  assert.equal(AP.outboxSlice('s1', { offset: 40, limit: 20 }).posts.length
+             + AP.outboxSlice('s1', { offset: 40, limit: 20 }).tracks.length, 10);
+  const voorbij = AP.outboxSlice('s1', { offset: 60, limit: 20 });
+  assert.equal(voorbij.posts.length + voorbij.tracks.length, 0);
+});
+
+test('de collectie biedt een next zolang er meer is', () => {
+  const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+  const { posts, tracks, totaal } = AP.outboxSlice('s1', { offset: 0, limit: 20 });
+  const ob = AP.buildOutbox('https://ons.test', site, posts, tracks, { page: 1, totalItems: totaal, alGesneden: true });
+  assert.equal(ob.totalItems, 50);
+  assert.equal(ob.orderedItems.length, 20);
+  assert.equal(ob.next, 'https://ons.test/ap/users/dev/outbox?page=2',
+    'zonder alGesneden zou een volle pagina zichzelf als de enige zien');
+});
+
+test('een fans-only post blijft buiten de telling van een vreemde', () => {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at, fan_only)
+              VALUES (?,?,?,?,?,?,'published',?,1)`)
+    .run('geheim', 's1', 'u1', 'geheim', 'Geheim', '<p>x</p>', dag(28));
+  assert.equal(AP.outboxSlice('s1', { fanOnly: false }).totaal, 50, 'een vreemde telt hem niet mee');
+  assert.equal(AP.outboxSlice('s1', { fanOnly: true }).totaal, 51, 'een vriend wel');
+});
