a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

Add image transform support for post cover thumbnails (#309)

* Use Supabase image transform for publication homepage cover thumbnails

Publication homepage post lists displayed full-resolution cover image
blobs (proxied from the PDS via /api/atproto_images) at small thumbnail
sizes. Now, when a cover image is present, request a downscaled version:
the proxy caches the blob in the url-previews bucket and redirects to a
Supabase image-transform URL sized for the variant, avoiding shipping the
full-size image just to render a thumbnail.

* Apply Supabase thumbnail transform to feed and standard-site post covers

Extend the cover-image thumbnail transform to the reader feed
(PostListing) and the standard-site post block (StandardSitePostItem),
which also rendered full-resolution cover blobs at thumbnail sizes.

* Stream transformed cover thumbnails instead of redirecting

Replace the 307 redirect to Supabase's public transform URL with
streaming the transformed bytes directly through the proxy. This keeps
thumbnails to a single round trip served with our own immutable cache
headers, avoiding the extra redirect hop on cold browser loads. The blob
is still cached in the url-previews bucket and downscaled via Supabase's
image transform on first request.

* Cache transformed cover thumbnails at the edge for a year

These thumbnails are derived from content-addressed (immutable) CIDs at a
fixed width, so they never change. Bump the thumbnail edge cache
(CDN-Cache-Control) from one day to a year to match the browser TTL and
avoid needless re-pulls of immutable content.

* Cache full-resolution PDS blobs at the edge for a year too

Full images are addressed by the same content-addressed (immutable) CIDs
as thumbnails, so bump their edge cache (CDN-Cache-Control) from one day
to a year as well. Generalize the shared constant now that both paths use
it.

* Simplify CDN cache comment

* Simplify thumbnail transform changes

- Reuse the shared supabaseServerClient instead of re-creating the
service-role client inline in the atproto_images route.
- Centralize the duplicated 800/360 thumbnail widths into a single
COVER_THUMBNAIL_WIDTH constant used by all three cover-image call sites.

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Jared Pereira
Claude
and committed by
GitHub
(Jun 1, 2026, 11:37 AM EDT) 13a08ddd 007c7a40

+136 -15
+10 -2
app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx
··· 14 14 type NormalizedPublication, 15 15 } from "src/utils/normalizeRecords"; 16 16 import { getFirstParagraph } from "src/utils/getFirstParagraph"; 17 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 17 + import { blobRefToSrc, COVER_THUMBNAIL_WIDTH } from "src/utils/blobRefToSrc"; 18 18 19 19 export type PublicationPostsListPost = { 20 20 uri: string; ··· 123 123 : "medium"; 124 124 125 125 const postDid = new AtUri(post.uri).host; 126 + // Request a downscaled thumbnail (via Supabase image transform) 127 + // sized for how the cover image is displayed in each variant, 128 + // rather than shipping the full-resolution blob. 126 129 const coverImageSrc = doc_record.coverImage 127 - ? blobRefToSrc(doc_record.coverImage.ref, postDid) 130 + ? blobRefToSrc(doc_record.coverImage.ref, postDid, undefined, { 131 + width: 132 + Variant === "large" 133 + ? COVER_THUMBNAIL_WIDTH.large 134 + : COVER_THUMBNAIL_WIDTH.medium, 135 + }) 128 136 : undefined; 129 137 130 138 if (Variant === "large") {
+100 -8
app/api/atproto_images/route.ts
··· 1 + export const runtime = "nodejs"; 2 + 1 3 import { IdResolver } from "@atproto/identity"; 2 4 import { NextRequest, NextResponse } from "next/server"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 3 6 4 7 let idResolver = new IdResolver(); 8 + 9 + // Reuse the existing image-cache bucket (it already has Supabase image 10 + // transformations enabled — see app/api/link_previews/route.ts). 11 + const COVER_IMAGE_BUCKET = "url-previews"; 12 + const COVER_IMAGE_PREFIX = "post-cover"; 13 + const CACHE_CONTROL = 14 + "public, max-age=31536000, immutable, s-maxage=86400, stale-while-revalidate=604800"; 15 + // CID-addressed content never changes, so cache it at the edge for a year. 16 + const CDN_CACHE_CONTROL = 17 + "public, s-maxage=31536000, immutable, stale-while-revalidate=604800"; 5 18 6 19 /** 7 20 * Fetches a blob from an AT Protocol PDS given a DID and CID ··· 31 44 return response; 32 45 } 33 46 47 + function parseDimension(value: string | null): number | undefined { 48 + if (!value) return undefined; 49 + const parsed = Number.parseInt(value, 10); 50 + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; 51 + // Clamp to a sane upper bound so the proxy can't be used to request 52 + // arbitrarily large transforms. 53 + return Math.min(parsed, 2000); 54 + } 55 + 56 + /** 57 + * Caches the PDS blob in Supabase storage (CIDs are content-addressed, so the 58 + * cache is immutable) and returns a downscaled version of it via Supabase's 59 + * image transform. We stream the bytes back ourselves (rather than redirecting 60 + * to the public transform URL) so the thumbnail is a single round trip served 61 + * with our own immutable cache headers. Returns null if it can't be produced. 62 + */ 63 + async function getTransformedBlob( 64 + did: string, 65 + cid: string, 66 + transform: { width?: number; height?: number }, 67 + ): Promise<Blob | null> { 68 + if (!did || !cid) return null; 69 + 70 + const path = `${COVER_IMAGE_PREFIX}/${cid}`; 71 + 72 + // Only fetch + upload the full blob if it isn't already cached. 73 + const { data: existing } = await supabaseServerClient.storage 74 + .from(COVER_IMAGE_BUCKET) 75 + .list(COVER_IMAGE_PREFIX, { limit: 1, search: cid }); 76 + 77 + if (!existing || existing.length === 0) { 78 + const response = await fetchAtprotoBlob(did, cid); 79 + if (!response) return null; 80 + 81 + const { error } = await supabaseServerClient.storage 82 + .from(COVER_IMAGE_BUCKET) 83 + .upload(path, await response.arrayBuffer(), { 84 + contentType: response.headers.get("content-type") || undefined, 85 + cacheControl: "public, max-age=31536000, immutable", 86 + upsert: true, 87 + }); 88 + if (error) { 89 + console.log("failed to cache cover image for transform", error); 90 + return null; 91 + } 92 + } 93 + 94 + const { data, error } = await supabaseServerClient.storage 95 + .from(COVER_IMAGE_BUCKET) 96 + .download(path, { 97 + transform: { 98 + width: transform.width, 99 + height: transform.height, 100 + resize: "cover", 101 + }, 102 + }); 103 + if (error || !data) { 104 + console.log("failed to fetch transformed cover image", error); 105 + return null; 106 + } 107 + return data; 108 + } 109 + 34 110 export async function GET(req: NextRequest) { 35 111 const url = new URL(req.url); 36 112 const params = { ··· 38 114 cid: url.searchParams.get("cid") ?? "", 39 115 }; 40 116 117 + const width = parseDimension(url.searchParams.get("width")); 118 + const height = parseDimension(url.searchParams.get("height")); 119 + 120 + // When thumbnail dimensions are requested, stream back a downscaled version 121 + // (via Supabase's image transform) instead of the full-resolution blob. 122 + if (width || height) { 123 + const transformed = await getTransformedBlob(params.did, params.cid, { 124 + width, 125 + height, 126 + }); 127 + if (transformed) { 128 + return new NextResponse(transformed, { 129 + headers: { 130 + "Content-Type": transformed.type || "image/jpeg", 131 + "Cache-Control": CACHE_CONTROL, 132 + "CDN-Cache-Control": CDN_CACHE_CONTROL, 133 + }, 134 + }); 135 + } 136 + // Fall through to the original blob if the transform couldn't be produced. 137 + } 138 + 41 139 const response = await fetchAtprotoBlob(params.did, params.cid); 42 140 if (!response) return new NextResponse(null, { status: 404 }); 43 141 ··· 45 143 const cachedResponse = new Response(response.body, response); 46 144 47 145 // Set cache-control header to cache indefinitely 48 - cachedResponse.headers.set( 49 - "Cache-Control", 50 - "public, max-age=31536000, immutable, s-maxage=86400, stale-while-revalidate=604800", 51 - ); 52 - cachedResponse.headers.set( 53 - "CDN-Cache-Control", 54 - "s-maxage=86400, stale-while-revalidate=86400", 55 - ); 146 + cachedResponse.headers.set("Cache-Control", CACHE_CONTROL); 147 + cachedResponse.headers.set("CDN-Cache-Control", CDN_CACHE_CONTROL); 56 148 57 149 return cachedResponse; 58 150 }
+7 -2
components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx
··· 12 12 getPublicationURL, 13 13 } from "app/(app)/lish/createPub/getPublicationURL"; 14 14 import { getFirstParagraph } from "src/utils/getFirstParagraph"; 15 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { blobRefToSrc, COVER_THUMBNAIL_WIDTH } from "src/utils/blobRefToSrc"; 16 16 import { useStandardSitePost } from "components/StandardSitePostDataProvider"; 17 17 import { useEntity, useReplicache } from "src/replicache"; 18 18 import { InteractionPreview } from "components/InteractionsPreview"; ··· 184 184 } 185 185 const coverImageSrc = 186 186 post.record.coverImage && postDid 187 - ? blobRefToSrc(post.record.coverImage.ref, postDid) 187 + ? blobRefToSrc(post.record.coverImage.ref, postDid, undefined, { 188 + width: 189 + size === "large" 190 + ? COVER_THUMBNAIL_WIDTH.large 191 + : COVER_THUMBNAIL_WIDTH.medium, 192 + }) 188 193 : undefined; 189 194 190 195 const { rootEntity } = useReplicache();
+7 -2
components/PostListing.tsx
··· 3 3 import { PubIcon } from "components/ActionBar/Publications"; 4 4 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 5 5 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 6 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 6 + import { blobRefToSrc, COVER_THUMBNAIL_WIDTH } from "src/utils/blobRefToSrc"; 7 7 import type { 8 8 NormalizedDocument, 9 9 NormalizedPublication, ··· 128 128 {postRecord.coverImage && ( 129 129 <div className="postListingImage"> 130 130 <img 131 - src={blobRefToSrc(postRecord.coverImage.ref, postUri.host)} 131 + src={blobRefToSrc( 132 + postRecord.coverImage.ref, 133 + postUri.host, 134 + undefined, 135 + { width: COVER_THUMBNAIL_WIDTH.large }, 136 + )} 132 137 alt={postRecord.title || ""} 133 138 className="w-full h-auto aspect-video object-cover object-top-left rounded" 134 139 />
+12 -1
src/utils/blobRefToSrc.ts
··· 6 6 // If `$link` is already an http(s) URL we return it untouched — the email-preview 7 7 // path stuffs a direct draft-image URL into `$link` since drafts aren't uploaded 8 8 // to a PDS yet. 9 + // `transform` requests a downscaled version of the image via Supabase's image 10 + // transformation pipeline (handled in /api/atproto_images). Use it for 11 + // thumbnails so we don't ship the full-resolution blob to render a small image. 9 12 export const blobRefToSrc = ( 10 13 b: BlobRef["ref"], 11 14 did: string, 12 15 baseUrl?: string, 16 + transform?: { width?: number; height?: number }, 13 17 ) => { 14 18 const link = (b as unknown as { $link: string })["$link"]; 15 19 if (link.startsWith("http://") || link.startsWith("https://")) return link; 16 20 const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : ""; 17 - return `${prefix}/api/atproto_images?did=${did}&cid=${link}`; 21 + let src = `${prefix}/api/atproto_images?did=${did}&cid=${link}`; 22 + if (transform?.width) src += `&width=${transform.width}`; 23 + if (transform?.height) src += `&height=${transform.height}`; 24 + return src; 18 25 }; 26 + 27 + // Display widths (px) for cover-image thumbnails, used to request a right-sized 28 + // transform instead of shipping the full-resolution blob. 29 + export const COVER_THUMBNAIL_WIDTH = { large: 800, medium: 360 };