Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 88788148df903019ffbc5b7d8b3dca7d1d759712)
+++ src/config/database.js	(revision 2d6a9c360f54275fa0b6256c64c88bf2d5782554)
@@ -387,4 +387,9 @@
   ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
   ensureColumn('ap_followers', 'last_error_at', 'DATETIME');    // last time a delivery to it gave up (max retries)
+
+  // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
+  // @mentions linkified once at save). `content` stays the raw source used for editing and
+  // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
+  ensureColumn('posts', 'content_rendered', 'TEXT');
 }
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 88788148df903019ffbc5b7d8b3dca7d1d759712)
+++ src/routes/posts.js	(revision 2d6a9c360f54275fa0b6256c64c88bf2d5782554)
@@ -238,4 +238,15 @@
 }
 
+// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
+// source (used by the editor + re-rendering), content_rendered holds the linkified render the
+// page serves. Called after every create/edit. Non-fatal: the render route falls back to
+// baking on the fly if this ever fails.
+function cacheRenderedContent(postId, rawContent) {
+  try {
+    db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
+      .run(ActivityPubService.bakePostContent(rawContent || ''), postId);
+  } catch (e) { /* fallback bake in the render route keeps display correct */ }
+}
+
 router.post('/posts/create', requireAuth, (req, res) => {
   const site = res.locals.site;
@@ -299,4 +310,5 @@
     now, now, publishedAt
   );
+  cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
 
   // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
@@ -439,4 +451,5 @@
     finalSlug, publishedAt, now, post.id
   );
+  cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
 
   // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
@@ -994,8 +1007,12 @@
   if (post.status === 'published') recordPostView(post, req);
 
-  // Render content. Content is now user-authored HTML (already sanitized on
-  // save). The pipeline still adds autoembed iframes and shortcode embeds:
-  //   stored HTML → autoembed → [[track]]/[[album]]/[[playlist]] → response
-  let html = post.content || '';
+  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
+  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
+  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
+  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
+  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
+  let html = (post.content_rendered != null && post.content_rendered !== '')
+    ? post.content_rendered
+    : ActivityPubService.bakePostContent(post.content || '');
   if (audioEnabled()) {
   if (site.enable_audio_player !== 0) {
@@ -1092,8 +1109,5 @@
     html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
   }
-  // Linkify inline #hashtags and bare URLs for on-site display — same rules as the
-  // federated copy (buildNote). Was: only the Mastodon copy got links; the website
-  // showed raw "#tag"/URL text. Idempotent, so editor links + embeds are untouched.
-  html = ActivityPubService.linkifyBody('', html);
+  // (linkify is baked into content_rendered at save now, not re-run here.)
   post.content_html = html;
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 88788148df903019ffbc5b7d8b3dca7d1d759712)
+++ src/services/ActivityPubService.js	(revision 2d6a9c360f54275fa0b6256c64c88bf2d5782554)
@@ -1464,4 +1464,12 @@
 }
 
+// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
+// at save and cached in posts.content_rendered, so page views serve it statically instead of
+// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
+// resolve @mentions here too (webfinger once at save instead of per page view).
+export function bakePostContent(source) {
+  return linkifyBody('', source || '');
+}
+
 // Extract the AP Hashtag tag objects from already-linked reply content.
 function hashtagTags(base, content) {
@@ -2463,4 +2471,4 @@
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
   getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
-  linkifyBody, listFollowers, removeFollower,
+  linkifyBody, bakePostContent, listFollowers, removeFollower,
 };
