/pricing and /blog invisible to LLMs — fix SSR bailout (#45292)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

push client directive to leaf interactive components, keep content in
server shell

## What is the current behavior?

/pricing and blog pages are rendered it client side so crawlers don't
get any content on fetch.



Verifiy content with
```bash curl -s http://localhost:3000/pricing | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w
curl -s http://localhost:3000/blog | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w
curl -s http://localhost:3000/blog/<some-slug> | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Featured blog post now displays prominently on the blog landing page
  * Added smooth anchor link navigation on the pricing page
* Improved table of contents tracking with scroll-to-anchor behavior on
blog posts
* Enhanced pricing comparison and plans sections with better data
integration

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Alan Daniel authored and GitHub committed 2026-04-28 10:13:58 +03:00
1 parent 96939829bc
commit 939b4034d9
11 files changed
+249 -205

No files matched your search

+65 -78
View File
@@ -1,16 +1,13 @@
'use client'
import { useState, useCallback } from 'react'
import DefaultLayout from 'components/Layouts/Default'
import { isBrowser, LOCAL_STORAGE_KEYS } from 'common'
import BlogFilters from 'components/Blog/BlogFilters'
import BlogGridItem from 'components/Blog/BlogGridItem'
import BlogListItem from 'components/Blog/BlogListItem'
import BlogFilters from 'components/Blog/BlogFilters'
import FeaturedThumb from 'components/Blog/FeaturedThumb'
import { cn } from 'ui'
import { LOCAL_STORAGE_KEYS, isBrowser } from 'common'
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
import { useCallback, useState } from 'react'
import type PostTypes from 'types/post'
import { cn } from 'ui'
export type BlogView = 'list' | 'grid'
@@ -74,14 +71,20 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
const [filteredTotal, setFilteredTotal] = useState<number | null>(null)
const isList = view === 'list'
// Determine which posts and total to use based on filter state
const currentPosts = filteredPosts ?? initialBlogs
const currentTotal = filteredTotal ?? totalPosts
const fetchMorePosts = useCallback(
async (offset: number, limit: number) => {
const isFiltered =
(filterParams.category && filterParams.category !== 'all') || Boolean(filterParams.search)
// The featured post is rendered above the list (not in `items`), so the
// API offset has to skip past it for unfiltered fetches. Filtered
// results come from a separate query and don't share that hero slot.
const apiOffset = isFiltered ? offset : offset + 1
const params = new URLSearchParams({
offset: offset.toString(),
offset: apiOffset.toString(),
limit: limit.toString(),
})
@@ -118,9 +121,7 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
rootMargin: '1000px',
})
// Handle filter changes - fetch filtered results from API
const handleFilterChange = useCallback(async (category?: string, search?: string) => {
// If no filters, reset to initial state
if ((!category || category === 'all') && !search) {
setFilterParams({})
setFilteredPosts(null)
@@ -159,78 +160,64 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
}
}, [])
const featuredPost = initialBlogs[0]
return (
<>
<DefaultLayout>
<h1 className="sr-only">Supabase blog</h1>
<div className="container relative mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
{featuredPost && <FeaturedThumb key={featuredPost.slug} {...featuredPost} />}
</div>
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
<div className="border-default border-t">
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
<ol
className={cn(
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
)}
>
{isFiltering ? (
// Show skeletons while filtering
isList ? (
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
<div
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
key={`skeleton-list-${idx}`}
>
<BlogListItemSkeleton />
</div>
))
) : (
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
<div
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
key={`skeleton-grid-${idx}`}
>
<BlogGridItemSkeleton />
</div>
))
)
) : blogs?.length ? (
blogs?.map((blog: PostTypes, idx: number) =>
isList ? (
<div
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
key={`list-${idx}-${blog.slug}`}
>
<BlogListItem post={blog} />
</div>
) : (
<div
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
key={`grid-${idx}-${blog.slug}`}
>
<BlogGridItem post={blog} />
</div>
)
)
) : (
<p className="text-sm text-light col-span-full">No results</p>
)}
</ol>
{hasMore && !isFiltering && (
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
<ol
className={cn(
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
)}
>
{isFiltering ? (
isList ? (
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
<div
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
key={`skeleton-list-${idx}`}
>
<BlogListItemSkeleton />
</div>
)}
</div>
))
) : (
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
<div
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
key={`skeleton-grid-${idx}`}
>
<BlogGridItemSkeleton />
</div>
))
)
) : blogs?.length ? (
blogs?.map((blog: PostTypes, idx: number) =>
isList ? (
<div
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
key={`list-${idx}-${blog.slug}`}
>
<BlogListItem post={blog} />
</div>
) : (
<div
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
key={`grid-${idx}-${blog.slug}`}
>
<BlogGridItem post={blog} />
</div>
)
)
) : (
<p className="text-sm text-light col-span-full">No results</p>
)}
</ol>
{hasMore && !isFiltering && (
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
</div>
</DefaultLayout>
)}
</>
)
}
@@ -0,0 +1,10 @@
'use client'
import useActiveAnchors from '@/hooks/useActiveAnchors'
// Mounts the scroll-to-anchor + TOC active-section tracking listeners.
// Lives in its own client island so the surrounding blog post markup can SSR.
export default function BlogPostAnchorEffect() {
useActiveAnchors()
return null
}
+13 -22
View File
@@ -1,14 +1,9 @@
'use client'
import dynamic from 'next/dynamic'
import useActiveAnchors from '@/hooks/useActiveAnchors'
import BlogPostAnchorEffect from './BlogPostAnchorEffect'
import BlogPostRenderer from '@/components/Blog/BlogPostRenderer'
import authors from '@/lib/authors.json'
import { isNotNullOrUndefined } from '@/lib/helpers'
import type { Blog, BlogData, PostReturnType, ProcessedBlogData } from '@/types/post'
const BlogPostRenderer = dynamic(() => import('@/components/Blog/BlogPostRenderer'))
type BlogPostPageProps = {
prevPost: PostReturnType | null
nextPost: PostReturnType | null
@@ -17,14 +12,7 @@ type BlogPostPageProps = {
isDraftMode: boolean
}
// Note: convertRichTextToMarkdown is now imported from the shared utility
export default function BlogPostClient(props: BlogPostPageProps) {
const isDraftMode = props.isDraftMode
// Enable scroll-to-anchor functionality and TOC highlighting
useActiveAnchors()
const blogMetaData = props.blog
const blogAuthors = (blogMetaData.author ?? '')
@@ -38,13 +26,16 @@ export default function BlogPostClient(props: BlogPostPageProps) {
.filter(isNotNullOrUndefined)
return (
<BlogPostRenderer
blog={props.blog as ProcessedBlogData}
blogMetaData={blogMetaData as ProcessedBlogData}
isDraftMode={isDraftMode}
prevPost={props.prevPost}
nextPost={props.nextPost}
authors={blogAuthors}
/>
<>
<BlogPostAnchorEffect />
<BlogPostRenderer
blog={props.blog as ProcessedBlogData}
blogMetaData={blogMetaData as ProcessedBlogData}
isDraftMode={props.isDraftMode}
prevPost={props.prevPost}
nextPost={props.nextPost}
authors={blogAuthors}
/>
</>
)
}
+23 -5
View File
@@ -1,4 +1,7 @@
import FeaturedThumb from 'components/Blog/FeaturedThumb'
import DefaultLayout from 'components/Layouts/Default'
import type { Metadata } from 'next'
import type PostTypes from 'types/post'
import BlogClient from './BlogClient'
import { getSortedPosts } from '@/lib/posts'
@@ -19,19 +22,34 @@ export const metadata: Metadata = {
const INITIAL_POSTS_LIMIT = 25
export default async function BlogPage() {
// Get static blog posts
const staticPostsData = getSortedPosts({ directory: '_blog', runner: '** BLOG PAGE **' })
// Sort by date
const allPosts = [...staticPostsData].sort((a, b) => {
const dateA = new Date(a.date || a.formattedDate).getTime()
const dateB = new Date(b.date || b.formattedDate).getTime()
return dateB - dateA
})
// Only send initial posts to client, rest will be loaded via API
const initialPosts = allPosts.slice(0, INITIAL_POSTS_LIMIT)
const totalPosts = allPosts.length
const featuredPost = initialPosts[0]
// Featured post is rendered as the hero above the list, so exclude it from
// the list to avoid showing it twice. BlogClient compensates the API offset
// when scrolling for more posts.
const listPosts = initialPosts.slice(1)
const totalListPosts = Math.max(0, allPosts.length - 1)
return <BlogClient initialBlogs={initialPosts} totalPosts={totalPosts} />
return (
<DefaultLayout>
<h1 className="sr-only">Supabase blog</h1>
<div className="container relative mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
{featuredPost && <FeaturedThumb key={featuredPost.slug} {...(featuredPost as PostTypes)} />}
</div>
<div className="border-default border-t">
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
<BlogClient initialBlogs={listPosts} totalPosts={totalListPosts} />
</div>
</div>
</DefaultLayout>
)
}
+29
View File
@@ -0,0 +1,29 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
// Scrolls to the section matching window.location.hash on mount and on
// pathname change. We render a mobile and a desktop row for each item, so the
// id-based anchors are suffixed with `-mobile` / `-desktop` and we pick the
// right one based on viewport width.
export default function HashAnchorScroll() {
const pathname = usePathname()
useEffect(() => {
const hash = window.location.hash.slice(1)
if (!hash) return
let device = 'desktop'
if (window.matchMedia('screen and (max-width: 1024px)').matches) {
device = 'mobile'
}
const element = document.querySelector(`#${hash}-${device}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}, [pathname])
return null
}
@@ -0,0 +1,16 @@
'use client'
import PricingComparisonTable from '~/components/Pricing/PricingComparisonTable'
import { useOrganizations } from '~/data/organizations'
export default function PricingComparisonSection() {
const { isLoading, organizations } = useOrganizations()
const hasExistingOrganizations = !isLoading && organizations.length > 0
return (
<PricingComparisonTable
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
)
}
+12 -97
View File
@@ -1,18 +1,18 @@
'use client'
import { ArrowDownIcon } from '@heroicons/react/outline'
import CTABanner from '~/components/CTABanner'
import DefaultLayout from '~/components/Layouts/Default'
import PricingPlans from '~/components/Pricing/PricingPlans'
import { useOrganizations } from '~/data/organizations'
import { hasConsented, posthogClient } from 'common'
import PricingAddons from '~/components/Pricing/PricingAddons'
import PricingComputeSection from '~/components/Pricing/PricingComputeSection'
import PricingDiskSection from '~/components/Pricing/PricingDiskSection'
import PricingFAQs from '~/components/Pricing/PricingFAQs'
import { ArrowUpRight } from 'lucide-react'
import dynamic from 'next/dynamic'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Button } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
const EXPERIMENT_ID = 'pricingPageExperiment' as const
import HashAnchorScroll from './HashAnchorScroll'
import PricingComparisonSection from './PricingComparisonSection'
import PricingPlansSection from './PricingPlansSection'
export type PricingPageExperimentVariant =
| 'control'
// GROWTH-694: flexibility/hourly billing
@@ -23,77 +23,10 @@ export type PricingPageExperimentVariant =
| 'multi_project' // "first project included, additional from $10/mo" on Pro card
| 'estimate_cta' // "estimate your cost" link on Pro card scrolling to calculator
const PricingComputeSection = dynamic(() => import('~/components/Pricing/PricingComputeSection'))
const PricingAddons = dynamic(() => import('~/components/Pricing/PricingAddons'))
const PricingComparisonTable = dynamic(() => import('~/components/Pricing/PricingComparisonTable'))
const PricingFAQs = dynamic(() => import('~/components/Pricing/PricingFAQs'))
const CTABanner = dynamic(() => import('~/components/CTABanner'))
const PricingDiskSection = dynamic(() => import('~/components/Pricing/PricingDiskSection'))
export default function PricingContent() {
const pathname = usePathname()
// Ability to scroll into pricing sections like storage
useEffect(() => {
/**
* As we render a mobile and a desktop row for each item and just display based on screen size, we cannot navigate by simple id hash
* on both mobile and desktop. To handle both cases, we actually need to check screen size
*/
const hash = window.location.hash.slice(1)
if (!hash) return
let device = 'desktop'
if (window.matchMedia('screen and (max-width: 1024px)').matches) {
device = 'mobile'
}
const element = document.querySelector(`#${hash}-${device}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}, [pathname])
const { isLoading, organizations } = useOrganizations()
const hasExistingOrganizations = !isLoading && organizations.length > 0
// Pricing value/flexibility A/B experiment
// Uses client-side PostHog directly — server-side evaluation lacks full person context for www pages.
// DevToolbar overrides (x-ph-flag-overrides cookie) are respected in local dev via posthogClient.getFeatureFlag.
const [flagValue, setFlagValue] = useState<PricingPageExperimentVariant | false | undefined>(
() =>
posthogClient.getFeatureFlag(EXPERIMENT_ID) as
| PricingPageExperimentVariant
| false
| undefined
)
useEffect(() => {
return posthogClient.onFeatureFlags(() => {
const value = posthogClient.getFeatureFlag(EXPERIMENT_ID)
setFlagValue(value as PricingPageExperimentVariant | false | undefined)
})
}, [])
const validVariants: PricingPageExperimentVariant[] = [
'control',
'flexibility',
'flexibility_card',
'hourly_rate',
'multi_project',
'estimate_cta',
]
const isInExperiment = validVariants.includes(flagValue as PricingPageExperimentVariant)
const showFlexibilitySection = flagValue === 'flexibility'
useEffect(() => {
if (!isInExperiment) return
posthogClient.captureExperimentExposure(EXPERIMENT_ID, { variant: flagValue }, hasConsented())
}, [isInExperiment, flagValue])
return (
<DefaultLayout>
<HashAnchorScroll />
<div className="relative z-10 pt-8 pb-4 xl:py-16">
<div className="mx-auto max-w-7xl px-8 text-center sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl space-y-2 lg:max-w-none">
@@ -108,22 +41,7 @@ export default function PricingContent() {
</div>
</div>
<PricingPlans
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
experimentVariant={isInExperiment ? (flagValue as PricingPageExperimentVariant) : undefined}
/>
{showFlexibilitySection && (
<div className="mx-auto max-w-xl px-8 mt-10 xl:mt-16 text-center">
<p className="text-foreground text-sm">
Need more power for a launch? Scale up instantly.{' '}
<span className="text-foreground-light">
Scale back down after — you only pay for the hours you use.
</span>
</p>
</div>
)}
<PricingPlansSection />
<div className="text-center mt-10 xl:mt-16 mx-auto max-w-lg flex flex-col gap-8">
<div className="flex justify-center gap-2">
@@ -179,10 +97,7 @@ export default function PricingContent() {
<PricingAddons />
</div>
<PricingComparisonTable
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
/>
<PricingComparisonSection />
<div id="faq" className="border-t">
<PricingFAQs />
@@ -0,0 +1,72 @@
'use client'
import PricingPlans from '~/components/Pricing/PricingPlans'
import { useOrganizations } from '~/data/organizations'
import { hasConsented, posthogClient } from 'common'
import { useEffect, useState } from 'react'
import type { PricingPageExperimentVariant } from './PricingContent'
const EXPERIMENT_ID = 'pricingPageExperiment' as const
const VALID_VARIANTS: PricingPageExperimentVariant[] = [
'control',
'flexibility',
'flexibility_card',
'hourly_rate',
'multi_project',
'estimate_cta',
]
export default function PricingPlansSection() {
const { isLoading, organizations } = useOrganizations()
const hasExistingOrganizations = !isLoading && organizations.length > 0
// Pricing value/flexibility A/B experiment.
// Uses client-side PostHog directly — server-side evaluation lacks full person context for www pages.
// DevToolbar overrides (x-ph-flag-overrides cookie) are respected in local dev via posthogClient.getFeatureFlag.
const [flagValue, setFlagValue] = useState<PricingPageExperimentVariant | false | undefined>(
() =>
posthogClient.getFeatureFlag(EXPERIMENT_ID) as
| PricingPageExperimentVariant
| false
| undefined
)
useEffect(() => {
return posthogClient.onFeatureFlags(() => {
const value = posthogClient.getFeatureFlag(EXPERIMENT_ID)
setFlagValue(value as PricingPageExperimentVariant | false | undefined)
})
}, [])
const isInExperiment = VALID_VARIANTS.includes(flagValue as PricingPageExperimentVariant)
const showFlexibilitySection = flagValue === 'flexibility'
useEffect(() => {
if (!isInExperiment) return
posthogClient.captureExperimentExposure(EXPERIMENT_ID, { variant: flagValue }, hasConsented())
}, [isInExperiment, flagValue])
return (
<>
<PricingPlans
organizations={organizations}
hasExistingOrganizations={hasExistingOrganizations}
experimentVariant={isInExperiment ? (flagValue as PricingPageExperimentVariant) : undefined}
/>
{showFlexibilitySection && (
<div className="mx-auto max-w-xl px-8 mt-10 xl:mt-16 text-center">
<p className="text-foreground text-sm">
Need more power for a launch? Scale up instantly.{' '}
<span className="text-foreground-light">
Scale back down after — you only pay for the hours you use.
</span>
</p>
</div>
)}
</>
)
}
+5 -3
View File
@@ -1,7 +1,9 @@
import React, { PropsWithChildren, useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { cn } from 'ui'
'use client'
import { detectBrowser, isBrowser } from 'common'
import { motion } from 'framer-motion'
import React, { PropsWithChildren, useEffect, useRef } from 'react'
import { cn } from 'ui'
interface Props {
outerClassName?: string
@@ -1,3 +1,5 @@
'use client'
import { ChevronDownIcon } from '@heroicons/react/outline'
import Link from 'next/link'
import React, { useEffect, useRef, useState } from 'react'
@@ -1,3 +1,5 @@
'use client'
import pricingFaq from '~/data/PricingFAQ.json'
import React from 'react'
import ReactMarkdown from 'react-markdown'