fix(www): use imgSocial for blog OG images (#43842)

## What kind of change does this PR introduce?

- Bug fix that resolves DEPR-396
- Additional improvements to blog post image handling

## What is the current behavior?

Blog post Open Graph metadata often prefers `imgThumb` over `imgSocial`,
so social previews on X, iMessage, and similar surfaces can render the
on-site thumbnail instead of the intended social image.

The image selection and path-normalization rules are also duplicated
across blog surfaces, which makes the precedence rules easy to drift.

## What is the new behavior?

- Centralizes blog image handling in `apps/www/lib/blog-images.ts`
- Uses `imgSocial` first, then `imgThumb`, for blog OG/Twitter metadata
- Uses `imgThumb` first, then `imgSocial`, then the placeholder, for
blog thumbnails and post hero images
- Normalizes relative blog image paths into absolute URLs for metadata
- Adds warning-only validation during content reads/builds for partial
or malformed `imgSocial` / `imgThumb` config
- Updates a few recent blog posts so `imgThumb` is a thumbnail-only
asset instead of duplicating the social image

## Additional context

- Replaces #42319 with the additional above fixes
- Added unit coverage for the shared image helper

---------

Co-authored-by: Alan Daniel <stylesshjs@gmail.com>
This commit is contained in:
Danny WhiteandAlan Daniel authored and GitHub committed 2026-03-19 02:45:11 +00:00
1 parent 676c237422
commit 48b96e8a79
14 files changed
+351 -54

No files matched your search

@@ -15,11 +15,9 @@ tags:
- infrastructure
toc_depth: 3
imgSocial: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only&copy=When+to+use%0A%5BRead+Replicas%5D%0Avs.+bigger+compute'
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only&copy=When+to+use%0A%5BRead+Replicas%5D%0Avs.+bigger+compute'
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=ruler&layout=icon-only&icon=icon-database.svg'
---
# When to use Read Replicas vs. bigger compute
When your database starts slowing down, you face a choice: make your existing database bigger, or spread the load across multiple databases. Both approaches work. Neither is universally correct. The right answer depends on your workload, your budget, and where the bottleneck actually is.
This post walks through how to diagnose what is causing your database to slow down, when vertical scaling (bigger compute) makes sense, when horizontal read scaling (Read Replicas) is the better path, and how to make the decision with real numbers.
@@ -11,7 +11,7 @@ tags:
- mcp
- integrations
imgSocial: '2026-02-03-supabase-is-now-an-official-claude-connector/og.png'
imgThumb: '2026-02-03-supabase-is-now-an-official-claude-connector/thumb.png'
imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=partnerships&layout=icon-only&icon=supabase.svg&icon2=1770136381461-Claude.svg'
toc_depth: 2
---
@@ -3,7 +3,7 @@ title: 'X / Twitter OAuth 2.0 is now available for Supabase Auth'
description: 'You can now add "Sign in with X" to your application using the new X / Twitter (OAuth 2.0) provider in Supabase Auth.'
author: fady
imgSocial: x-twitter-oauth-2-provider/og.png
imgThumb: x-twitter-oauth-2-provider/og.png
imgThumb: https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=ruler&layout=icon-only&copy=Introducing+Supabase%0Afor+%5BPlatforms%5D&icon=social-x.svg
categories:
- product
tags:
+2 -3
View File
@@ -3,6 +3,7 @@ import { draftMode } from 'next/headers'
import BlogPostClient from './BlogPostClient'
import { SITE_ORIGIN } from '@/lib/constants'
import { getAbsoluteBlogSocialImage } from '@/lib/blog-images'
import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts'
import type { Blog, BlogData, PostReturnType } from '@/types/post'
@@ -40,7 +41,6 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
}
}
const { isEnabled: isDraft } = await draftMode()
const matter = (await import('gray-matter')).default
// Try to get static markdown post first
@@ -48,8 +48,7 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
const postContent = await getPostdata(slug, '_blog')
const parsedContent = matter(postContent) as unknown as MatterReturn
const blogPost = parsedContent.data
const blogImage = blogPost.imgThumb || blogPost.imgSocial
const metaImageUrl = blogImage && blogImage.startsWith('http') ? blogImage : undefined
const metaImageUrl = getAbsoluteBlogSocialImage(blogPost, SITE_ORIGIN)
return {
title: blogPost.title,
+8 -12
View File
@@ -3,6 +3,11 @@ import Image from 'next/image'
import Link from 'next/link'
import authors from '@/lib/authors.json'
import {
BLOG_GRID_IMAGE_SIZES,
BLOG_PLACEHOLDER_IMAGE,
getBlogThumbnailImage,
} from '@/lib/blog-images'
import type Author from '@/types/author'
import type PostTypes from '@/types/post'
@@ -24,15 +29,7 @@ const BlogGridItem = ({ post }: Props) => {
}
}
const resolveImagePath = (img: string | undefined): string | null => {
if (!img) return null
return img.startsWith('/') || img.startsWith('http') ? img : `/images/blog/${img}`
}
const imageUrl =
resolveImagePath(post.imgThumb) ||
resolveImagePath(post.imgSocial) ||
'/images/blog/blog-placeholder.png'
const imageUrl = getBlogThumbnailImage(post) ?? BLOG_PLACEHOLDER_IMAGE
return (
<Link
@@ -45,8 +42,7 @@ const BlogGridItem = ({ post }: Props) => {
<div className="border-default relative mb-3 w-full aspect-[1.91/1] overflow-hidden rounded-lg border shadow-sm">
<Image
fill
sizes="100%"
quality={100}
sizes={BLOG_GRID_IMAGE_SIZES}
src={imageUrl}
className="scale-100 object-cover overflow-hidden"
alt={`${post.title} thumbnail`}
@@ -58,7 +54,7 @@ const BlogGridItem = ({ post }: Props) => {
<p>{dayjs(post.date).format('D MMM YYYY')}</p>
{post.readingTime && (
<>
<p>•</p>
<p>·</p>
<p>{post.readingTime}</p>
</>
)}
@@ -12,6 +12,7 @@ import type { ComponentType } from 'react'
import type { PostReturnType, ProcessedBlogData, StaticAuthor, Tag } from 'types/post'
import { Badge } from 'ui'
import { BLOG_POST_HERO_IMAGE_SIZES, getBlogThumbnailImage } from '@/lib/blog-images'
import mdxComponents from '@/lib/mdx/mdxComponents'
const ShareArticleActions = dynamic(() => import('@/components/Blog/ShareArticleActions'))
@@ -131,11 +132,9 @@ const BlogPostRenderer = ({
</div>
)
const imageUrl = blogMetaData.imgThumb
? blogMetaData.imgThumb.startsWith('/') || blogMetaData.imgThumb.startsWith('http')
? blogMetaData.imgThumb
: `/images/blog/${blogMetaData.imgThumb}`
: ''
const imageUrl = getBlogThumbnailImage(blogMetaData, {
fallbackToPlaceholder: false,
})
return (
<>
@@ -168,7 +167,7 @@ const BlogPostRenderer = ({
<h1 className="text-2xl sm:text-4xl">{blogMetaData.title}</h1>
<div className="text-light flex space-x-3 text-sm">
<p>{dayjs(blogMetaData.date).format('DD MMM YYYY')}</p>
<p>•</p>
<p>·</p>
<p>{(blogMetaData as any).readingTime}</p>
</div>
{authors.length > 0 && (
@@ -231,14 +230,13 @@ const BlogPostRenderer = ({
allowFullScreen={true}
/>
) : (
blogMetaData.imgThumb && (
imageUrl && (
<div className="hidden md:block relative mb-8 w-full aspect-[1.91/1] overflow-auto rounded-lg border">
<Image
src={imageUrl}
alt={blogMetaData.title}
fill
quality={100}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
sizes={BLOG_POST_HERO_IMAGE_SIZES}
className="object-cover m-0"
/>
</div>
+8 -12
View File
@@ -2,6 +2,11 @@ import Image from 'next/image'
import Link from 'next/link'
import authors from '@/lib/authors.json'
import {
BLOG_FEATURED_IMAGE_SIZES,
BLOG_PLACEHOLDER_IMAGE,
getBlogThumbnailImage,
} from '@/lib/blog-images'
import type PostTypes from '@/types/post'
function FeaturedThumb(blog: PostTypes) {
@@ -22,15 +27,7 @@ function FeaturedThumb(blog: PostTypes) {
}
function renderFeaturedThumb(blog: PostTypes, author: any[]) {
const resolveImagePath = (img: string | undefined): string | null => {
if (!img) return null
return img.startsWith('/') || img.startsWith('http') ? img : `/images/blog/${img}`
}
const imageUrl =
resolveImagePath(blog.imgThumb) ||
resolveImagePath(blog.imgSocial) ||
'/images/blog/blog-placeholder.png'
const imageUrl = getBlogThumbnailImage(blog) ?? BLOG_PLACEHOLDER_IMAGE
return (
<div key={blog.slug} className="w-full">
@@ -42,8 +39,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
<Image
src={imageUrl}
fill
sizes="100%"
quality={100}
sizes={BLOG_FEATURED_IMAGE_SIZES}
priority
className="object-cover bg-alternative"
alt="blog thumbnail"
@@ -52,7 +48,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
<div className="flex flex-col space-y-2 lg:col-span-4 xl:justify-center max-w-xl">
<div className="text-lighter flex space-x-2 text-sm">
<span>{blog.formattedDate}</span>
<span>•</span>
<span>·</span>
<span>{blog.readingTime}</span>
</div>
+4 -4
View File
@@ -1,12 +1,12 @@
import React from 'react'
import dayjs from 'dayjs'
// import authors from 'lib/authors.json'
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
// import type Author from '~/types/author'
import type PostTypes from '../../types/post'
import dayjs from 'dayjs'
interface Props {
event: PostTypes
}
@@ -58,7 +58,7 @@ const EventGridItem = ({ event }: Props) => {
<p>{dayjs(event.date).format('D MMM YYYY')}</p>
{event.readingTime && (
<>
<p>•</p>
<p>·</p>
<p>{event.readingTime}</p>
</>
)}
+3 -9
View File
@@ -3,10 +3,10 @@ import { MDXRemote } from 'next-mdx-remote'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import React from 'react'
import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import { getAbsoluteBlogSocialImage } from '~/lib/blog-images'
import { generateReadingTime } from '~/lib/helpers'
interface Props {
@@ -31,8 +31,6 @@ const LayoutComparison = ({ components, props }: Props) => {
)
}
const { basePath } = useRouter()
const NextCard = (props: any) => {
const { post, label, className } = props
return (
@@ -77,12 +75,8 @@ const LayoutComparison = ({ components, props }: Props) => {
}),
},
images: (() => {
const img = props.blog.imgSocial || props.blog.imgThumb
if (!img) return []
const url =
img.startsWith('/') || img.startsWith('http')
? img
: `https://supabase.com${basePath}/images/blog/${img}`
const url = getAbsoluteBlogSocialImage(props.blog, 'https://supabase.com')
if (!url) return []
return [{ url }]
})(),
}}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import {
BLOG_PLACEHOLDER_IMAGE,
getAbsoluteBlogSocialImage,
getBlogSocialImage,
getBlogThumbnailImage,
resolveBlogImagePath,
validateBlogFrontmatterImages,
} from './blog-images'
describe('blog image helpers', () => {
it('prefers imgThumb for on-site blog thumbnails', () => {
expect(
getBlogThumbnailImage({
imgThumb: 'example/thumb.png',
imgSocial: 'example/og.png',
})
).toBe('/images/blog/example/thumb.png')
})
it('falls back to imgSocial for on-site blog thumbnails', () => {
expect(
getBlogThumbnailImage({
imgSocial: 'example/og.png',
})
).toBe('/images/blog/example/og.png')
})
it('prefers imgSocial for social metadata', () => {
expect(
getBlogSocialImage({
imgThumb: 'example/thumb.png',
imgSocial: 'example/og.png',
})
).toBe('/images/blog/example/og.png')
})
it('returns absolute URLs unchanged and prefixes relative paths', () => {
expect(resolveBlogImagePath('https://example.com/og.png')).toBe('https://example.com/og.png')
expect(resolveBlogImagePath('/images/blog/example/og.png')).toBe('/images/blog/example/og.png')
expect(resolveBlogImagePath('example/og.png')).toBe('/images/blog/example/og.png')
})
it('builds absolute social image URLs', () => {
expect(
getAbsoluteBlogSocialImage(
{
imgSocial: 'example/og.png',
},
'https://supabase.com'
)
).toBe('https://supabase.com/images/blog/example/og.png')
})
it('uses the placeholder when neither image is present', () => {
expect(getBlogThumbnailImage({})).toBe(BLOG_PLACEHOLDER_IMAGE)
expect(getBlogThumbnailImage({}, { fallbackToPlaceholder: false })).toBeUndefined()
})
it('warns when blog frontmatter is missing one of the image fields', () => {
const warn = vi.fn()
validateBlogFrontmatterImages(
{
imgThumb: 'example/thumb.png',
},
'/tmp/example-post.mdx',
warn
)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing "imgSocial"'))
})
it('warns when a blog image uses the prefixed public path', () => {
const warn = vi.fn()
validateBlogFrontmatterImages(
{
imgThumb: '/images/blog/example/thumb.png',
imgSocial: 'example/og.png',
},
'/tmp/example-prefixed-post.mdx',
warn
)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('should not include the "/images/blog/" prefix')
)
})
})
+142
View File
@@ -0,0 +1,142 @@
import { SITE_ORIGIN } from './constants'
type BlogImageFields = {
imgSocial?: string
imgThumb?: string
}
type ValidationWarn = (message?: unknown, ...optionalParams: unknown[]) => void
const warnedBlogImageIssues = new Set<string>()
export const BLOG_PLACEHOLDER_IMAGE = '/images/blog/blog-placeholder.png'
export const BLOG_GRID_IMAGE_SIZES = '(max-width: 1023px) 100vw, (max-width: 1279px) 50vw, 33vw'
export const BLOG_FEATURED_IMAGE_SIZES = '(max-width: 1023px) 100vw, 42vw'
export const BLOG_POST_HERO_IMAGE_SIZES = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
function isAbsoluteUrl(image: string) {
return image.startsWith('http://') || image.startsWith('https://')
}
function warnOnce(key: string, message: string, warn: ValidationWarn) {
if (warnedBlogImageIssues.has(key)) return
warnedBlogImageIssues.add(key)
warn(message)
}
export function resolveBlogImagePath(image: string) {
if (isAbsoluteUrl(image) || image.startsWith('/')) {
return image
}
return `/images/blog/${image}`
}
export function toAbsoluteBlogImageUrl(image: string, siteOrigin: string = SITE_ORIGIN) {
const resolvedImage = resolveBlogImagePath(image)
if (isAbsoluteUrl(resolvedImage)) {
return resolvedImage
}
return `${siteOrigin}${resolvedImage}`
}
export function getBlogThumbnailImage(
{ imgThumb, imgSocial }: BlogImageFields,
options: { fallbackToPlaceholder?: boolean } = {}
) {
const image = imgThumb || imgSocial
if (!image) {
return options.fallbackToPlaceholder === false ? undefined : BLOG_PLACEHOLDER_IMAGE
}
return resolveBlogImagePath(image)
}
export function getBlogSocialImage({ imgThumb, imgSocial }: BlogImageFields) {
const image = imgSocial || imgThumb
return image ? resolveBlogImagePath(image) : undefined
}
export function getAbsoluteBlogSocialImage(
{ imgThumb, imgSocial }: BlogImageFields,
siteOrigin: string = SITE_ORIGIN
) {
const image = imgSocial || imgThumb
return image ? toAbsoluteBlogImageUrl(image, siteOrigin) : undefined
}
export function validateBlogFrontmatterImages(
frontmatter: BlogImageFields,
filePath: string,
warn: ValidationWarn = console.warn
) {
const hasImgSocial = typeof frontmatter.imgSocial !== 'undefined'
const hasImgThumb = typeof frontmatter.imgThumb !== 'undefined'
if (hasImgSocial && !hasImgThumb) {
warnOnce(
`${filePath}:imgThumb:missing`,
`[blog images] ${filePath}: missing "imgThumb". Adding it keeps on-site thumbnails separate from social previews.`,
warn
)
}
if (hasImgThumb && !hasImgSocial) {
warnOnce(
`${filePath}:imgSocial:missing`,
`[blog images] ${filePath}: missing "imgSocial". Adding it keeps social previews separate from on-site thumbnails.`,
warn
)
}
const imageFields = [
['imgSocial', frontmatter.imgSocial],
['imgThumb', frontmatter.imgThumb],
] as const
for (const [fieldName, imageValue] of imageFields) {
if (typeof imageValue === 'undefined') {
continue
}
if (typeof imageValue !== 'string') {
warnOnce(
`${filePath}:${fieldName}:invalid-type`,
`[blog images] ${filePath}: "${fieldName}" should be a string URL or a relative blog image path.`,
warn
)
continue
}
const trimmedValue = imageValue.trim()
if (!trimmedValue) {
warnOnce(
`${filePath}:${fieldName}:empty`,
`[blog images] ${filePath}: "${fieldName}" is empty. Remove it or provide a valid image path.`,
warn
)
continue
}
if (trimmedValue.startsWith('/images/blog/')) {
warnOnce(
`${filePath}:${fieldName}:prefixed`,
`[blog images] ${filePath}: "${fieldName}" should not include the "/images/blog/" prefix. Use a relative path like "my-post/og.png" instead.`,
warn
)
}
if (trimmedValue.startsWith('./') || trimmedValue.startsWith('../')) {
warnOnce(
`${filePath}:${fieldName}:relative-dot`,
`[blog images] ${filePath}: "${fieldName}" should use a clean relative blog path, not "${trimmedValue}".`,
warn
)
}
}
}
+11
View File
@@ -1,6 +1,7 @@
import fs from 'fs'
import matter from 'gray-matter'
import path from 'path'
import { validateBlogFrontmatterImages } from './blog-images'
import { generateReadingTime } from './helpers'
type Directories = '_blog' | '_case-studies' | '_customers' | '_alternatives' | '_events'
@@ -66,6 +67,10 @@ export const getSortedPosts = ({
content: string
}
if (directory === '_blog') {
validateBlogFrontmatterImages(data as { imgSocial?: string; imgThumb?: string }, fullPath)
}
const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric', year: 'numeric' }
const formattedDate = new Date(data.date).toLocaleDateString('en-IN', options)
@@ -169,6 +174,12 @@ export const getPostdata = async (slug: string, directory: string) => {
const fullPath = path.join(postDirectory, found)
const postContent = fs.readFileSync(fullPath, 'utf8')
if (directory === '_blog') {
const { data } = matter(postContent) as unknown as { data: { [key: string]: any } }
validateBlogFrontmatterImages(data as { imgSocial?: string; imgThumb?: string }, fullPath)
}
return postContent
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -13,6 +13,76 @@ dayjs.extend(advancedFormat)
// Constants
const FILENAME_SUBSTRING = 11 // based on YYYY-MM-DD format
const warnedBlogImageIssues = new Set()
const warnBlogImageIssue = (key, message) => {
if (warnedBlogImageIssues.has(key)) return
warnedBlogImageIssues.add(key)
console.warn(message)
}
const validateBlogFrontmatterImages = (frontmatter, filePath) => {
const hasImgSocial = typeof frontmatter.imgSocial !== 'undefined'
const hasImgThumb = typeof frontmatter.imgThumb !== 'undefined'
if (hasImgSocial && !hasImgThumb) {
warnBlogImageIssue(
`${filePath}:imgThumb:missing`,
`[blog images] ${filePath}: missing "imgThumb". Adding it keeps on-site thumbnails separate from social previews.`
)
}
if (hasImgThumb && !hasImgSocial) {
warnBlogImageIssue(
`${filePath}:imgSocial:missing`,
`[blog images] ${filePath}: missing "imgSocial". Adding it keeps social previews separate from on-site thumbnails.`
)
}
const imageFields = [
['imgSocial', frontmatter.imgSocial],
['imgThumb', frontmatter.imgThumb],
]
for (const [fieldName, imageValue] of imageFields) {
if (typeof imageValue === 'undefined') {
continue
}
if (typeof imageValue !== 'string') {
warnBlogImageIssue(
`${filePath}:${fieldName}:invalid-type`,
`[blog images] ${filePath}: "${fieldName}" should be a string URL or a relative blog image path.`
)
continue
}
const trimmedValue = imageValue.trim()
if (!trimmedValue) {
warnBlogImageIssue(
`${filePath}:${fieldName}:empty`,
`[blog images] ${filePath}: "${fieldName}" is empty. Remove it or provide a valid image path.`
)
continue
}
if (trimmedValue.startsWith('/images/blog/')) {
warnBlogImageIssue(
`${filePath}:${fieldName}:prefixed`,
`[blog images] ${filePath}: "${fieldName}" should not include the "/images/blog/" prefix. Use a relative path like "my-post/og.png" instead.`
)
}
if (trimmedValue.startsWith('./') || trimmedValue.startsWith('../')) {
warnBlogImageIssue(
`${filePath}:${fieldName}:relative-dot`,
`[blog images] ${filePath}: "${fieldName}" should use a clean relative blog path, not "${trimmedValue}".`
)
}
}
}
/**
* Fixes Safari dates sorting bug
@@ -53,6 +123,8 @@ const getStaticBlogPosts = () => {
const fileContents = fsSync.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents)
validateBlogFrontmatterImages(data, fullPath)
const options = { month: 'long', day: 'numeric', year: 'numeric' }
const formattedDate = new Date(data.date).toLocaleDateString('en-IN', options)