diff --git a/apps/www/_blog/2026-01-15-read-replicas-vs-bigger-compute.mdx b/apps/www/_blog/2026-01-15-read-replicas-vs-bigger-compute.mdx index 47e22c22323..e4ad71ff345 100644 --- a/apps/www/_blog/2026-01-15-read-replicas-vs-bigger-compute.mdx +++ b/apps/www/_blog/2026-01-15-read-replicas-vs-bigger-compute.mdx @@ -15,11 +15,9 @@ tags: - infrastructure toc_depth: 3 imgSocial: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only©=When+to+use%0A%5BRead+Replicas%5D%0Avs.+bigger+compute' -imgThumb: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=text-only©=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. diff --git a/apps/www/_blog/2026-02-03-supabase-is-now-an-official-claude-connector.mdx b/apps/www/_blog/2026-02-03-supabase-is-now-an-official-claude-connector.mdx index ba4e7a09588..afcb6ea4b8b 100644 --- a/apps/www/_blog/2026-02-03-supabase-is-now-an-official-claude-connector.mdx +++ b/apps/www/_blog/2026-02-03-supabase-is-now-an-official-claude-connector.mdx @@ -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 --- diff --git a/apps/www/_blog/2026-02-06-x-twitter-oauth-2-provider.mdx b/apps/www/_blog/2026-02-06-x-twitter-oauth-2-provider.mdx index bdcd9261c87..3faf08ee7f1 100644 --- a/apps/www/_blog/2026-02-06-x-twitter-oauth-2-provider.mdx +++ b/apps/www/_blog/2026-02-06-x-twitter-oauth-2-provider.mdx @@ -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©=Introducing+Supabase%0Afor+%5BPlatforms%5D&icon=social-x.svg categories: - product tags: diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx index 243f343da55..e2f68cd513b 100644 --- a/apps/www/app/blog/[slug]/page.tsx +++ b/apps/www/app/blog/[slug]/page.tsx @@ -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 }): } } - 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 }): 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, diff --git a/apps/www/components/Blog/BlogGridItem.tsx b/apps/www/components/Blog/BlogGridItem.tsx index 8489fe4d785..cda53ea0c2d 100644 --- a/apps/www/components/Blog/BlogGridItem.tsx +++ b/apps/www/components/Blog/BlogGridItem.tsx @@ -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 ( {
{`${post.title} {

{dayjs(post.date).format('D MMM YYYY')}

{post.readingTime && ( <> -

•

+

·

{post.readingTime}

)} diff --git a/apps/www/components/Blog/BlogPostRenderer.tsx b/apps/www/components/Blog/BlogPostRenderer.tsx index 395610377ad..65d1c69725f 100644 --- a/apps/www/components/Blog/BlogPostRenderer.tsx +++ b/apps/www/components/Blog/BlogPostRenderer.tsx @@ -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 = ({
) - 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 = ({

{blogMetaData.title}

{dayjs(blogMetaData.date).format('DD MMM YYYY')}

-

•

+

·

{(blogMetaData as any).readingTime}

{authors.length > 0 && ( @@ -231,14 +230,13 @@ const BlogPostRenderer = ({ allowFullScreen={true} /> ) : ( - blogMetaData.imgThumb && ( + imageUrl && (
{blogMetaData.title}
diff --git a/apps/www/components/Blog/FeaturedThumb.tsx b/apps/www/components/Blog/FeaturedThumb.tsx index 0fc6b6e52f3..72244182e97 100644 --- a/apps/www/components/Blog/FeaturedThumb.tsx +++ b/apps/www/components/Blog/FeaturedThumb.tsx @@ -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 (
@@ -42,8 +39,7 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) { blog thumbnail
{blog.formattedDate} - • + · {blog.readingTime}
diff --git a/apps/www/components/Events/EventGridItem.tsx b/apps/www/components/Events/EventGridItem.tsx index a16bee905ea..0d137a6c6e2 100644 --- a/apps/www/components/Events/EventGridItem.tsx +++ b/apps/www/components/Events/EventGridItem.tsx @@ -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) => {

{dayjs(event.date).format('D MMM YYYY')}

{event.readingTime && ( <> -

•

+

·

{event.readingTime}

)} diff --git a/apps/www/layouts/comparison.tsx b/apps/www/layouts/comparison.tsx index a5a9949371b..4873f609cec 100644 --- a/apps/www/layouts/comparison.tsx +++ b/apps/www/layouts/comparison.tsx @@ -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 }] })(), }} diff --git a/apps/www/lib/blog-images.test.ts b/apps/www/lib/blog-images.test.ts new file mode 100644 index 00000000000..09f2ecf879b --- /dev/null +++ b/apps/www/lib/blog-images.test.ts @@ -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') + ) + }) +}) diff --git a/apps/www/lib/blog-images.ts b/apps/www/lib/blog-images.ts new file mode 100644 index 00000000000..af5145eee7d --- /dev/null +++ b/apps/www/lib/blog-images.ts @@ -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() + +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 + ) + } + } +} diff --git a/apps/www/lib/posts.tsx b/apps/www/lib/posts.tsx index 494a7c138e1..6615fe0bc2d 100644 --- a/apps/www/lib/posts.tsx +++ b/apps/www/lib/posts.tsx @@ -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 } diff --git a/apps/www/public/images/blog/2026-02-03-supabase-is-now-an-official-claude-connector/thumb.png b/apps/www/public/images/blog/2026-02-03-supabase-is-now-an-official-claude-connector/thumb.png deleted file mode 100644 index 09a103cd53d..00000000000 Binary files a/apps/www/public/images/blog/2026-02-03-supabase-is-now-an-official-claude-connector/thumb.png and /dev/null differ diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index ad6dcc55614..92c0f35b7c6 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -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)