Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision b5c2538d199fb16a6685aa4a0abfc688075b7fd3)
+++ src/middleware/render.js	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
@@ -19,4 +19,5 @@
 import { getSetting, apEnabled } from '../services/SettingsService.js';
 import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
+import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js';
 import ActivityPubService from '../services/ActivityPubService.js';
 import { imgProxyUrl } from '../services/ThumbnailService.js';
@@ -164,4 +165,9 @@
     formatDate,
     formatDateTime,
+    // Render the Shaer-native bits server-side so the web timeline matches the
+    // apps: FEP-9098 custom emojis in content/names, and the FEP-044f quote.
+    emojiHtml,   // (html, emoji_json) → HTML with :shortcode: as <img>
+    emojiName,   // (text, emoji_json) → escaped name with :shortcode: as <img>
+    noteQuote: parseQuote,   // (quote_json) → the resolved quoted-post object or null
     // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
     // (crisp grid/list images). External URLs + already-thumb URLs pass through.
Index: src/services/NoteRender.js
===================================================================
--- src/services/NoteRender.js	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
+++ src/services/NoteRender.js	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
@@ -0,0 +1,96 @@
+// Server-side rendering of the bits the Shaer clients render natively, so the
+// Klonkt web timeline looks the same: FEP-9098 custom emojis (`:shortcode:` →
+// image) in note content and display names, and the FEP-044f embedded quote
+// card. Pure + deterministic (no DB, no I/O), so it is unit-testable and cheap.
+
+const SHORTCODE = /:[A-Za-z0-9_+-]+:/g;
+
+const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
+export function escapeHtml(s) {
+  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
+}
+function escapeAttr(s) {
+  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ESCAPES[c]);
+}
+
+// Normalise either representation into a { ":shortcode:": url } map:
+//  - emoji_json: an array of Emoji tag objects [{ name, icon:{url} }]
+//  - author_emoji_json / reblog_emoji_json / quote.emojis: already a map.
+export function emojiMap(json) {
+  try {
+    const v = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
+    if (!v) return {};
+    if (Array.isArray(v)) {
+      const m = {};
+      for (const t of v) {
+        const icon = t && t.icon;
+        const url = icon && (icon.url || (Array.isArray(icon) && icon[0] && icon[0].url));
+        if (t && typeof t.name === 'string' && url) m[t.name] = url;
+      }
+      return m;
+    }
+    if (typeof v === 'object') {
+      const m = {};
+      for (const k of Object.keys(v)) if (typeof v[k] === 'string') m[k] = v[k];
+      return m;
+    }
+    return {};
+  } catch { return {}; }
+}
+
+function emojiImg(url, alt) {
+  return `<img class="emoji" src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" title="${escapeAttr(alt)}" draggable="false" loading="lazy">`;
+}
+
+function substitute(text, map) {
+  return text.replace(SHORTCODE, (m) => (map[m] ? emojiImg(map[m], m) : m));
+}
+
+// Inject <img> for each known custom emoji into an already-sanitised HTML
+// fragment (note content). Substitutes only in text between tags (never inside
+// a tag or its attributes) and skips <code>/<pre>, mirroring the Shaer render.
+export function emojiHtml(html, json) {
+  const map = emojiMap(json);
+  if (!html || !Object.keys(map).length) return html || '';
+  let out = '';
+  let i = 0;
+  let code = 0;
+  while (i < html.length) {
+    if (html[i] === '<') {
+      const close = html.indexOf('>', i);
+      if (close < 0) { out += html.slice(i); break; }
+      const raw = html.slice(i + 1, close);
+      const name = raw.replace(/^\//, '').split(/[\s/>]/)[0].toLowerCase();
+      if (name === 'code' || name === 'pre') code = Math.max(0, code + (raw[0] === '/' ? -1 : 1));
+      out += html.slice(i, close + 1);   // copy the tag verbatim
+      i = close + 1;
+    } else {
+      const next = html.indexOf('<', i);
+      const end = next < 0 ? html.length : next;
+      const text = html.slice(i, end);
+      out += code > 0 ? text : substitute(text, map);
+      i = end;
+    }
+  }
+  return out;
+}
+
+// A plain-text display name with custom emojis → safe HTML. The name is HTML-
+// escaped first; shortcode characters ([A-Za-z0-9_+-]) survive escaping, so the
+// image substitution stays correct.
+export function emojiName(text, json) {
+  const esc = escapeHtml(text);
+  const map = emojiMap(json);
+  if (!Object.keys(map).length) return esc;
+  return substitute(esc, map);
+}
+
+// The resolved quoted-post snapshot Klonkt stored (quote_json), or null.
+export function parseQuote(json) {
+  try {
+    const q = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
+    return (q && typeof q === 'object' && q.url) ? q : null;
+  } catch { return null; }
+}
+
+export default { escapeHtml, emojiMap, emojiHtml, emojiName, parseQuote };
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision b5c2538d199fb16a6685aa4a0abfc688075b7fd3)
+++ src/views/pages/news.ejs	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
@@ -137,4 +137,24 @@
   .tl-readmore:hover { text-decoration: underline; }
 
+  /* FEP-9098 custom emojis: small inline images, never treated as media. */
+  img.emoji { height: 1.35em; width: auto; margin: 0 .04em; vertical-align: -0.22em;
+    display: inline-block; border-radius: 0; background: none; box-shadow: none; }
+  .tl-content img.emoji, .tl-quote-body img.emoji { max-width: none; }
+
+  /* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). */
+  .tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
+    border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
+    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
+  .tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
+  .tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
+  .tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
+  .tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+  .tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
+    overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
+  .tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
+  .tl-quote-body a { color: var(--accent, #06c); }
+  .tl-quote-media { display: block; margin: .45rem 0 0; }
+  .tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
+
   /* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
   .tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
Index: src/views/partials/quote-card.ejs
===================================================================
--- src/views/partials/quote-card.ejs	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
+++ src/views/partials/quote-card.ejs	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
@@ -0,0 +1,16 @@
+<%
+  // FEP-044f embedded quote card: the quoted post itself (author + content +
+  // first image), nested under a note — the web mirror of the Shaer QuoteCard.
+  // `q` is the resolved snapshot from noteQuote(p.quote_json).
+  var _qa = q.author || {};
+  var _qm = Array.isArray(q.media) ? q.media.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); }) : [];
+%>
+<div class="tl-quote">
+  <div class="tl-quote-head">
+    <% if (_qa.icon) { %><img class="tl-quote-avatar" src="<%= avatar(_qa.icon, 96) %>" alt="" loading="lazy"><% } %>
+    <span class="tl-quote-name"><%- emojiName(_qa.name || _qa.handle || '', _qa.emojis) %></span>
+    <% if (_qa.handle) { %><span class="tl-quote-handle"><%= _qa.handle %></span><% } %>
+  </div>
+  <% if (q.content) { %><div class="tl-quote-body"><%- emojiHtml(q.content, q.emojis) %></div><% } %>
+  <% if (_qm.length) { %><a class="tl-quote-media" href="<%= _qm[0].url %>" target="_blank" rel="noopener"><img src="<%= thumb(_qm[0].url, 640) %>" alt="" loading="lazy"></a><% } %>
+</div>
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision b5c2538d199fb16a6685aa4a0abfc688075b7fd3)
+++ src/views/partials/tl-item.ejs	(revision 3597763ebd5304800443a7a6c9ae0473d1e00272)
@@ -1,8 +1,8 @@
 <li class="tl-item">
-          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%= p.reblog_name %></strong> <%= t('tl.boosted') %></div><% } %>
+          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%- emojiName(p.reblog_name, p.reblog_emoji_json) %></strong> <%= t('tl.boosted') %></div><% } %>
           <div class="tl-head">
             <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 96) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
             <span class="tl-id">
-              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
+              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%- emojiName(p.author_name, p.author_emoji_json) %></a>
               <span class="tl-handle"><%= p.author_handle %></span>
             </span>
@@ -19,8 +19,9 @@
           %>
           <% if (_nsfwText) { %>
-            <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- p.content %></div>
+            <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- emojiHtml(p.content, p.emoji_json) %></div>
           <% } else { %>
-            <div class="tl-content"><%- p.content %></div>
+            <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div>
           <% } %>
+          <% var _quote = noteQuote(p.quote_json); if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
           <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
           <% if (imgs.length) { %>
