Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 1ff004363790da4fd47e4c572ebaa3bb8663d228)
+++ src/middleware/render.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
@@ -160,4 +160,9 @@
     formatDate,
     formatDateTime,
+    // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
+    // (crisp grid/list images). External URLs + already-thumb URLs pass through.
+    thumb: (url, w) => (typeof url === 'string' && url.startsWith('/media/') && !url.startsWith('/media/thumb/'))
+      ? `/media/thumb/${w || 480}/${url.slice(7)}`
+      : url,
     pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt',
     appVersion: APP_VERSION,
Index: src/server.js
===================================================================
--- src/server.js	(revision 1ff004363790da4fd47e4c572ebaa3bb8663d228)
+++ src/server.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
@@ -20,4 +20,5 @@
 import { SqliteSessionStore } from './services/SqliteSessionStore.js';
 import { ensurePrimarySite } from './services/ensurePrimarySite.js';
+import { getThumbnail, THUMB_SIZES } from './services/ThumbnailService.js';
 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
 import { isViewer } from './middleware/auth.js';
@@ -213,4 +214,24 @@
 
 app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
+
+// On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
+// (cached on disk), so the browser doesn't jaggily shrink a high-res cover for the grid/
+// list. Mounted BEFORE the /media static so it catches the thumb path first.
+app.get('/media/thumb/:w/*', async (req, res) => {
+  const w = parseInt(req.params.w, 10);
+  const rel = req.params[0] || '';
+  if (!THUMB_SIZES.has(w)) return res.status(400).end();
+  let file = null;
+  try { file = await getThumbnail(rel, w); } catch { /* fall through to original */ }
+  if (!file) {
+    // Generation unavailable/failed → serve the original instead of 404'ing.
+    return res.redirect(302, '/media/' + rel.split('/').map(encodeURIComponent).join('/'));
+  }
+  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
+  res.type('webp');
+  res.sendFile(file);
+});
+
 app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
   // Public media (post covers, avatars) must be cross-origin embeddable by other
Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/services/ThumbnailService.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
@@ -0,0 +1,76 @@
+/**
+ * On-demand cover thumbnails.
+ *
+ * High-res covers (especially line-art) look jagged because the BROWSER downscales
+ * them to the small grid/list size. We instead downscale the stored original
+ * server-side with ffmpeg's lanczos filter to a small WebP and cache it on disk, so
+ * the browser receives a near-1:1 image → crisp lines.
+ *
+ * No re-upload / backfill: the original file is only READ, never modified, and
+ * thumbnails are generated lazily on first request. Cover filenames are content-
+ * hashed/UUID, so a cached thumbnail can never go stale (a new cover = a new name).
+ *
+ * Uses the bundled `ffmpeg-static` (always present); cwebp is not required here.
+ */
+import { execFile } from 'child_process';
+import ffmpegPath from 'ffmpeg-static';
+import path from 'path';
+import fs from 'fs';
+import { promisify } from 'util';
+
+const execFileP = promisify(execFile);
+
+// Allowed widths (whitelist → no arbitrary-size abuse). 480 ≈ 2× a grid tile (retina).
+export const THUMB_SIZES = new Set([320, 480, 640]);
+
+let _seq = 0;
+
+function mediaRoot() {
+  return path.resolve(process.env.MEDIA_PATH || './storage/media');
+}
+
+// Resolve a safe absolute path for a relative media path; null on traversal attempts.
+function safeOriginal(rel) {
+  const root = mediaRoot();
+  const orig = path.resolve(root, rel);
+  if (orig !== root && !orig.startsWith(root + path.sep)) return null;
+  return orig;
+}
+
+/**
+ * Return the on-disk path of the cached thumbnail, generating it if needed.
+ * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
+ */
+export async function getThumbnail(rel, width) {
+  if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
+  const orig = safeOriginal(rel);
+  if (!orig || !fs.existsSync(orig)) return null;
+
+  const root = mediaRoot();
+  // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
+  const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
+  if (fs.existsSync(cached)) return cached;
+
+  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
+  const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
+  try {
+    await execFileP(ffmpegPath, [
+      '-hide_banner', '-loglevel', 'error', '-y',
+      '-i', orig,
+      // Downscale to `width` (never upscale past the original) with lanczos; even height.
+      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
+      '-frames:v', '1',
+      '-c:v', 'libwebp', '-q:v', '82',
+      // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
+      // can't infer the output format from it.
+      '-f', 'webp',
+      tmp,
+    ], { timeout: 20000 });
+    await fs.promises.rename(tmp, cached);
+    return cached;
+  } catch (e) {
+    try { await fs.promises.unlink(tmp); } catch {}
+    console.warn('[thumb] generation failed for', rel, '-', e.message);
+    return null;
+  }
+}
Index: src/views/partials/post-card.ejs
===================================================================
--- src/views/partials/post-card.ejs	(revision 1ff004363790da4fd47e4c572ebaa3bb8663d228)
+++ src/views/partials/post-card.ejs	(revision f79a471bf696cf519bf12755043ead300dd36476)
@@ -49,5 +49,5 @@
        <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
        aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
-      <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
+      <img src="<%= thumb(post.cover_image_url, 320) %>" alt="" loading="lazy" decoding="async">
       <% if (post.nsfw) { %><span class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= post.content_warning || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></span><% } %>
     </a>
Index: src/views/partials/post-tile.ejs
===================================================================
--- src/views/partials/post-tile.ejs	(revision 1ff004363790da4fd47e4c572ebaa3bb8663d228)
+++ src/views/partials/post-tile.ejs	(revision f79a471bf696cf519bf12755043ead300dd36476)
@@ -19,5 +19,5 @@
    <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
 
-  <% if (_hasCover) { %><img class="grid-tile-img" src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async"><% } %>
+  <% if (_hasCover) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"><% } %>
 
   <% if (_typeLabel) { %>
