diff --git a/apps/www/app/partners/catalog/IntegrationsContent.tsx b/apps/www/app/partners/catalog/IntegrationsContent.tsx
index de3e77f0fff..6da9eff20b0 100644
--- a/apps/www/app/partners/catalog/IntegrationsContent.tsx
+++ b/apps/www/app/partners/catalog/IntegrationsContent.tsx
@@ -3,7 +3,9 @@
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import Panel from '~/components/Panel'
+import { PARTNER_INTEGRATION_OVERRIDES } from '~/data/partner-integration-listings'
import { searchPartners } from '~/lib/marketplaceDb'
+import { ABSORBED_SLUGS } from '~/lib/partnerIntegrationListings'
import type { Partner } from '~/types/partners'
import { ArrowRight, ArrowUpRight, LayoutGrid, List, Loader, Search } from 'lucide-react'
import Image from 'next/image'
@@ -25,9 +27,15 @@ export default function IntegrationsContent({
metaTitle,
metaDescription,
}: Props) {
- const [partners, setPartners] = useState(initialPartners)
+ const [partners, setPartners] = useState(() =>
+ initialPartners.filter((p) => !ABSORBED_SLUGS.has(p.slug))
+ )
const allCategories = Array.from(
- new Set(initialPartners?.flatMap((p) => p.categories.map((c) => c.name)))
+ new Set(
+ initialPartners
+ .filter((p) => !ABSORBED_SLUGS.has(p.slug))
+ .flatMap((p) => p.categories.map((c) => c.name))
+ )
).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
const [search, setSearch] = useState('')
@@ -49,7 +57,8 @@ export default function IntegrationsContent({
const currentSearchId = ++searchIdRef.current
searchPartners(debouncedSearchTerm)
.then((results) => {
- if (currentSearchId === searchIdRef.current) setPartners(results ?? [])
+ if (currentSearchId === searchIdRef.current)
+ setPartners((results ?? []).filter((p) => !ABSORBED_SLUGS.has(p.slug)))
})
.catch(() => {
if (currentSearchId === searchIdRef.current) setPartners([])
@@ -69,6 +78,8 @@ export default function IntegrationsContent({
// When isUseMarketplaceDb is active, p.isMarketplace handles it automatically.
const MARKETPLACE_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend'])
const isOneClick = (p: Partner) => MARKETPLACE_SLUGS.has(p.slug)
+ const hasDashboardIntegrations = (p: Partner) =>
+ (PARTNER_INTEGRATION_OVERRIDES[p.slug]?.length ?? 0) > 0
const HAS_ACTIVE_FILTERS =
search.trim() !== '' || selectedCategories.length > 0 || partnerOnly || oneClickOnly
@@ -233,6 +244,11 @@ export default function IntegrationsContent({
{p.categories.map((c) => (
{c.name}
))}
+ {hasDashboardIntegrations(p) && (
+
+ Marketplace
+
+ )}
@@ -318,6 +334,11 @@ export default function IntegrationsContent({
{c.name}
))}
+ {hasDashboardIntegrations(p) && (
+
+ Dashboard
+
+ )}
@@ -363,6 +384,11 @@ export default function IntegrationsContent({
{c.name}
))}
+ {hasDashboardIntegrations(p) && (
+
+ Dashboard
+
+ )}
{p.description}
diff --git a/apps/www/data/partner-integration-listings.ts b/apps/www/data/partner-integration-listings.ts
new file mode 100644
index 00000000000..66ea3e58dbe
--- /dev/null
+++ b/apps/www/data/partner-integration-listings.ts
@@ -0,0 +1,84 @@
+import type { IntegrationListing } from '~/types/partners'
+
+type StudioIntegrationEntry = Omit<
+ IntegrationListing,
+ 'content' | 'images' | 'youtubeId' | 'installUrl' | 'isMarketplace'
+> & {
+ type: 'wrapper' | 'template'
+ dashboardUrl: string
+}
+
+/**
+ * Additional Studio-managed integration surfaces for catalog partners.
+ * Keyed by the partner's catalog slug.
+ *
+ * The "guide" listing is always derived from the Partner DB record itself.
+ * These entries appear as extra tabs when a partner has more than one integration surface.
+ *
+ * Adding a partner here is appropriate when:
+ * - The catalog entry is a general integration guide (e.g. an auth guide), AND
+ * - There is also a separate Studio wrapper/template that lets users query that partner's data
+ * via SQL or install a dashboard integration — a genuinely different surface.
+ *
+ * Do NOT add a partner here when the catalog content IS already the wrapper guide
+ * (single listing is correct in that case — the extra tab would just duplicate content).
+ *
+ * Pending content audit before adding: airtable, clickhouse, snowflake, paddle,
+ * notion, slack, hubspot, calendly, cal-com, orb, cloudflare-workers (D1 wrapper).
+ */
+export const PARTNER_INTEGRATION_OVERRIDES: Record = {
+ // ── Stripe ──────────────────────────────────────────────────────────────────
+ // Catalog: general payments integration guide
+ // + FDW: query Stripe objects via SQL
+ // + Template: full bidirectional sync engine
+ stripe: [
+ {
+ slug: 'stripe-wrapper',
+ name: 'Foreign Data Wrapper',
+ type: 'wrapper',
+ description:
+ 'Query your Stripe data directly via SQL using the Stripe Foreign Data Wrapper. Read accounts, customers, subscriptions, invoices, and more as Postgres tables.',
+ docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/stripe',
+ dashboardUrl: '/dashboard/project/_/integrations/stripe_wrapper/wrappers',
+ },
+ {
+ slug: 'stripe-sync-engine',
+ name: 'Stripe Sync Engine',
+ type: 'template',
+ description:
+ 'Continuously sync your Stripe payments, customers, and subscription data into your Postgres database using Supabase Queues and Edge Functions.',
+ docsUrl: 'https://github.com/stripe-experiments/sync-engine/',
+ dashboardUrl: '/dashboard/project/_/integrations/stripe_sync_engine',
+ },
+ ],
+
+ // ── Auth0 ────────────────────────────────────────────────────────────────────
+ // Catalog: auth integration guide (JWT / user migration)
+ // + FDW: query Auth0 users directly from Postgres
+ auth0: [
+ {
+ slug: 'auth0-wrapper',
+ name: 'Foreign Data Wrapper',
+ type: 'wrapper',
+ description:
+ 'Query your Auth0 users and management data directly from Postgres using the Auth0 Foreign Data Wrapper.',
+ docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/auth0',
+ dashboardUrl: '/dashboard/project/_/integrations/auth0_wrapper/wrappers',
+ },
+ ],
+
+ // ── Clerk ────────────────────────────────────────────────────────────────────
+ // Catalog: auth integration guide (JWT / session integration)
+ // + FDW: query Clerk users, organizations, and more from Postgres
+ clerk: [
+ {
+ slug: 'clerk-wrapper',
+ name: 'Foreign Data Wrapper',
+ type: 'wrapper',
+ description:
+ 'Query your Clerk users, organizations, memberships, and more directly from Postgres using the Clerk Foreign Data Wrapper.',
+ docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clerk',
+ dashboardUrl: '/dashboard/project/_/integrations/clerk_wrapper/wrappers',
+ },
+ ],
+}
diff --git a/apps/www/lib/marketplaceDb.ts b/apps/www/lib/marketplaceDb.ts
index 5949e916cd3..8200c431223 100644
--- a/apps/www/lib/marketplaceDb.ts
+++ b/apps/www/lib/marketplaceDb.ts
@@ -154,12 +154,10 @@ export async function searchPartners(search: string): Promise
}
async function getMarketplaceListing(slug: string): Promise {
- const { data } = await marketplaceClient
- .from('listings')
- .select('*')
- .eq('slug', slug)
- .is('publish_marketplace', true)
- .single()
+ // Do NOT filter by publish_marketplace here — the detail page should be reachable
+ // for any listing by direct URL, even if that listing isn't shown in the catalog list.
+ // publish_marketplace only gates the LIST query (listPartners / listPartnerSlugs).
+ const { data } = await marketplaceClient.from('listings').select('*').eq('slug', slug).single()
return data ? toPartner(data) : null
}
diff --git a/apps/www/lib/partnerIntegrationListings.ts b/apps/www/lib/partnerIntegrationListings.ts
new file mode 100644
index 00000000000..f2073c43b94
--- /dev/null
+++ b/apps/www/lib/partnerIntegrationListings.ts
@@ -0,0 +1,43 @@
+import { PARTNER_INTEGRATION_OVERRIDES } from '~/data/partner-integration-listings'
+import type { IntegrationListing, Partner } from '~/types/partners'
+
+/**
+ * Derives the list of IntegrationListings for a given partner.
+ *
+ * The first entry is always a "guide" listing built from the Partner DB record.
+ * Additional entries come from PARTNER_INTEGRATION_OVERRIDES (Studio wrappers, templates, etc.).
+ * When there is only one listing the detail page renders its content directly (no tabs).
+ * When there are multiple listings the detail page renders them as navigable tabs.
+ */
+export function partnerToIntegrationListings(partner: Partner): IntegrationListing[] {
+ const guide: IntegrationListing = {
+ slug: 'guide',
+ name: 'Guide',
+ type: 'guide',
+ description: partner.description,
+ content: partner.content,
+ images: partner.images,
+ youtubeId: partner.youtubeId,
+ installUrl: partner.installUrl,
+ isMarketplace: partner.isMarketplace,
+ docsUrl: partner.docsUrl,
+ }
+
+ const overrides = PARTNER_INTEGRATION_OVERRIDES[partner.slug] ?? []
+
+ return [guide, ...overrides]
+}
+
+/** Returns true when the partner has more than one integration surface. */
+export function hasMultipleListings(partnerSlug: string): boolean {
+ return (PARTNER_INTEGRATION_OVERRIDES[partnerSlug]?.length ?? 0) > 0
+}
+
+/**
+ * The set of catalog slugs that have been absorbed as tabs into another partner's page.
+ * These slugs may exist as standalone DB entries but should not appear in the catalog list
+ * or generate their own static pages — they are reachable only via the parent partner's tab.
+ */
+export const ABSORBED_SLUGS: ReadonlySet = new Set(
+ Object.values(PARTNER_INTEGRATION_OVERRIDES).flatMap((entries) => entries.map((e) => e.slug))
+)
diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js
index f695ae0d57c..ae0a6d105cd 100644
--- a/apps/www/lib/redirects.js
+++ b/apps/www/lib/redirects.js
@@ -2312,7 +2312,23 @@ module.exports = [
{
permanent: true,
source: '/partners/integrations/supabase_wrapper_stripe',
- destination: '/partners/catalog/stripe-wrapper',
+ destination: '/partners/catalog/stripe?tab=stripe-wrapper',
+ },
+ // Legacy direct slugs for FDW pages — now tabs on the partner page
+ {
+ permanent: true,
+ source: '/partners/catalog/stripe-wrapper',
+ destination: '/partners/catalog/stripe?tab=stripe-wrapper',
+ },
+ {
+ permanent: true,
+ source: '/partners/catalog/auth0-wrapper',
+ destination: '/partners/catalog/auth0?tab=auth0-wrapper',
+ },
+ {
+ permanent: true,
+ source: '/partners/catalog/clerk-wrapper',
+ destination: '/partners/catalog/clerk?tab=clerk-wrapper',
},
{
permanent: true,
diff --git a/apps/www/pages/partners/catalog/[slug].tsx b/apps/www/pages/partners/catalog/[slug].tsx
index 93e7c2fbf2d..77465aca7b9 100644
--- a/apps/www/pages/partners/catalog/[slug].tsx
+++ b/apps/www/pages/partners/catalog/[slug].tsx
@@ -9,6 +9,8 @@ import { serialize } from 'next-mdx-remote-client/serialize'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import Link from 'next/link'
+import { parseAsString, useQueryState } from 'nuqs'
+import { NuqsAdapter } from 'nuqs/adapters/next/pages'
import { useState, type Dispatch, type SetStateAction } from 'react'
import remarkGfm from 'remark-gfm'
@@ -17,34 +19,31 @@ import 'swiper/css'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import { getPartner, listPartnerSlugs } from '~/lib/marketplaceDb'
-import { type Partner } from '~/types/partners'
+import { ABSORBED_SLUGS, partnerToIntegrationListings } from '~/lib/partnerIntegrationListings'
+import { type IntegrationListing, type Partner } from '~/types/partners'
import { useBreakpoint } from 'common'
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
import { Swiper, SwiperSlide } from 'swiper/react'
-import { Button } from 'ui'
+import {
+ Button,
+ Tabs_Shadcn_,
+ TabsContent_Shadcn_,
+ TabsList_Shadcn_,
+ TabsTrigger_Shadcn_,
+} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import Error404 from '../../404'
-/**
- * Returns custom components so that the markdown converts to a nice looking html.
- */
function mdxComponents(callback: Dispatch>) {
- const components = {
+ return {
CH,
Admonition,
- /**
- * Returns a custom img element which has a bound onClick listener. When the image is clicked, it will open a modal showing that particular image.
- */
img: (
props: React.DetailedHTMLProps, HTMLImageElement>
- ) => {
- return callback(props.src!.toString())} />
- },
+ ) => callback(props.src!.toString())} />,
}
-
- return components
}
type PartnerData = {
@@ -52,9 +51,170 @@ type PartnerData = {
overview: MDXRemoteSerializeResult, Record>
}
-function Partner({ partner, overview }: PartnerData) {
+// ─── Tab content renderers ────────────────────────────────────────────────────
+
+function GuideTab({
+ listing,
+ overview,
+ focusedImage,
+ setFocusedImage,
+ partner,
+ isNarrow,
+}: {
+ listing: IntegrationListing
+ overview: PartnerData['overview']
+ focusedImage: string | null
+ setFocusedImage: Dispatch>
+ partner: Partner
+ isNarrow: boolean
+}) {
+ return (
+ <>
+ {(listing.images?.length ?? 0) > 0 && (
+
+
+
+ {listing.images?.map((image, i) => (
+
+
+
+ {focusedImage !== image && (
+ setFocusedImage(image)}
+ >
+
+
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {isNarrow &&
}
+
+
+ Overview
+
+
+ {'error' in overview ? (
+
Error rendering integration page: {overview.error.message}
+ ) : (
+ // @ts-expect-error: CodeHike puts its components under the CH namespace
+
+ )}
+
+
+ {!isNarrow &&
}
+
+
+ {listing.installUrl && (
+
+ )}
+ >
+ )
+}
+
+function DashboardIntegrationTab({
+ listing,
+ partner,
+ isNarrow,
+}: {
+ listing: IntegrationListing
+ partner: Partner
+ isNarrow: boolean
+}) {
+ const typeLabel = listing.type === 'wrapper' ? 'Foreign Data Wrapper' : 'Dashboard Integration'
+
+ return (
+
+ {isNarrow &&
}
+
+
+
+ {listing.name}
+
+ {listing.description && (
+
{listing.description}
+ )}
+
+
+
+
+ {!isNarrow &&
}
+
+ )
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+function PartnerInner({ partner, overview }: PartnerData) {
const [focusedImage, setFocusedImage] = useState(null)
const isNarrow = useBreakpoint('lg')
+ const listings = partnerToIntegrationListings(partner)
+ const isMulti = listings.length > 1
+
+ const [activeTabSlug, setActiveTabSlug] = useQueryState('tab', parseAsString)
+ const activeTab = listings.find((l) => l.slug === activeTabSlug) ?? listings[0]
if (!partner) return
@@ -67,11 +227,7 @@ function Partner({ partner, overview }: PartnerData) {
title: `${partner.title} | Works With Supabase`,
description: partner.description,
url: `https://supabase.com/partners/catalog/${partner.slug}`,
- images: [
- {
- url: partner.images ? partner.images[0] : partner.logo,
- },
- ],
+ images: [{ url: partner.images?.[0] ?? partner.logo }],
}}
/>
@@ -89,7 +245,7 @@ function Partner({ partner, overview }: PartnerData) {
e.stopPropagation()}
>
)}
+
- {/* Back button */}
+ {/* Back */}
+ {/* Partner header */}
@@ -129,122 +287,75 @@ function Partner({ partner, overview }: PartnerData) {
{partner.title}
- {partner.installUrl && (
+ {!isMulti && activeTab.installUrl && (
-
+
Add integration
)}
-
- {(partner.images?.length ?? 0) > 0 && (
-
-
- {partner.images?.map((image: any, i: number) => {
- return (
-
-
-
- {focusedImage !== image && (
- setFocusedImage(image)}
- >
-
-
- )}
-
-
-
- )
- })}
-
-
- )}
-
-
-
- {isNarrow &&
}
-
-
-
setActiveTabSlug(slug === listings[0].slug ? null : slug)}
+ className="mt-8"
+ >
+ {/* Full-bleed border — mirrors the SectionContainer structure so tabs align with content */}
+
- Overview
-
-
-
- {'error' in overview ? (
-
Error rendering integration page: {overview.error.message}
- ) : (
- // @ts-expect-error: Gildas - This is because CodeHide put its components under the CH namespace. It works but the MDXClient types are stricter
-
- )}
+
+
+
+ {listings.map((listing) => (
+
+ {listing.name}
+
+ ))}
+
+
+
-
- {!isNarrow && }
-
- {partner.installUrl && (
-
+ {listings.map((listing) => (
+
+ {listing.type === 'guide' ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ ) : (
+ // Single listing: render content directly, no tabs
+
)}
@@ -253,113 +364,102 @@ function Partner({ partner, overview }: PartnerData) {
)
}
-const PartnerDetails = ({ partner }: { partner: Partner }) => {
- const videoThumbnail = partner.youtubeId
- ? `https://img.youtube.com/vi/${partner.youtubeId}/0.jpg`
- : undefined
-
+function Partner({ partner, overview }: PartnerData) {
+ if (!partner) return
return (
-
-
-
- Details
-
+
+
+
+ )
+}
- {partner.youtubeId && (
-
+// ─── Details sidebar ─────────────────────────────────────────────────────────
+
+const PartnerDetails = ({ partner }: { partner: Partner }) => (
+
+
+
+ Details
+
+
+ {partner.youtubeId && (
+
+ )}
+
+
+ {partner.type === 'technology' && (
+
+ Developer
+ {partner.partnerName}
+
)}
-
- {partner.type === 'technology' && (
-
- Developer
- {partner.partnerName}
-
- )}
+ {partner.categories.map((category) => (
+
+ Category
+
+ {category.name}
+
+
+ ))}
- {partner.categories.map((category) => (
-
- Category
-
- {category.name}
-
-
- ))}
+
+ {partner.type === 'technology' && partner.docsUrl && (
-
- {partner.type === 'technology' && partner.docsUrl && (
-
- )}
-
-
- Third-party integrations and docs are managed by Supabase partners.
-
+ )}
-
- )
-}
-// This function gets called at build time
+
+ Third-party integrations and docs are managed by Supabase partners.
+
+
+
+)
+
+// ─── Data fetching ────────────────────────────────────────────────────────────
+
export const getStaticPaths: GetStaticPaths = async () => {
const slugs = await listPartnerSlugs()
-
- const paths: {
- params: { slug: string }
- locale?: string | undefined
- }[] =
- slugs?.map((slug) => ({
- params: {
- slug,
- },
- })) ?? []
-
- return {
- paths,
- fallback: 'blocking',
- }
+ const paths =
+ slugs?.filter((slug) => !ABSORBED_SLUGS.has(slug)).map((slug) => ({ params: { slug } })) ?? []
+ return { paths, fallback: 'blocking' }
}
-// This also gets called at build time
export const getStaticProps: GetStaticProps
= async ({ params }) => {
const partner = await getPartner(params!.slug as string)
- if (!partner) {
- return {
- notFound: true,
- }
- }
+ if (!partner) return { notFound: true }
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
@@ -369,7 +469,6 @@ export const getStaticProps: GetStaticProps = async ({ params }) =>
autoImport: false,
}
- // Parse markdown
const overview = await serialize({
source: partner.content,
options: {
@@ -379,10 +478,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) =>
},
})
- return {
- props: { partner, overview },
- revalidate: 1800, // 30 minutes
- }
+ return { props: { partner, overview }, revalidate: 1800 }
}
export default Partner
diff --git a/apps/www/types/partners.ts b/apps/www/types/partners.ts
index 3475ae35d4b..79179f2c2e5 100644
--- a/apps/www/types/partners.ts
+++ b/apps/www/types/partners.ts
@@ -25,6 +25,24 @@ export type Partner = {
youtubeId: string | null
}
+export type IntegrationListingType = 'guide' | 'wrapper' | 'template'
+
+export type IntegrationListing = {
+ slug: string
+ name: string
+ type: IntegrationListingType
+ description?: string
+ // guide-specific
+ content?: string | null
+ images?: string[]
+ youtubeId?: string | null
+ installUrl?: string | null
+ isMarketplace?: boolean
+ docsUrl?: string | null
+ // wrapper / template: links into the Studio dashboard
+ dashboardUrl?: string
+}
+
export function toPartner(dbPartner: DbPartner): Partner {
const {
featured,