feat(www): Partner Catalog update (#46757)

## Info architecture change around "Partners"

The www "integrations" now become more partner-driven.
`/partners/integrations` -> now Partner Catalog under
`/partners/catalog` (old links redirect to new paths)

Moved them close together in the nav dropdown and in the footer
<img width="494" height="336" alt="Screenshot 2026-07-09 at 11 06 41"
src="https://github.com/user-attachments/assets/a875fef0-0ab8-47ca-8756-d658b27c4892"
/>
<img width="1149" height="665" alt="Screenshot 2026-07-09 at 11 09 48"
src="https://github.com/user-attachments/assets/9631bb72-fe25-4fb4-b1af-9f14a37d02e7"
/>

## /partners 

This page remains untouched in this PR, updates to layout, content and
intake form are delegated to #47874

## /partners/catalog

Listed in the
[catalog](https://zone-www-dot-com-git-feat-www-partners-pages-supabase.vercel.app/partners/catalog)
are now partners. Some partners match with a listing.

<img width="1207" height="866" alt="Screenshot 2026-07-09 at 11 14 17"
src="https://github.com/user-attachments/assets/b65216be-976f-4ef5-91f8-1ad49da87b45"
/>

## /partners/catalog/[partner]

Each partner can have one or more "listings" which are either 
- simple guides
- foreign data wrappers
- dashboard integrations

Integrations available in the dashboard now all have a prominent
"Install integration" cta to open it in the dashboard [integrations
page](https://supabase.com/dashboard/project/_/integrations).

<img width="1269" height="776" alt="Screenshot 2026-07-09 at 11 16 51"
src="https://github.com/user-attachments/assets/3c7bb715-ffce-4d0a-905f-9a660c3b1f5a"
/>

## Docs

Update docs →
[Preview](https://docs-git-feat-www-partners-pages-supabase.vercel.app/docs/guides/integrations)
- remove "Supabase marketplace"
- use "Dashboard Integrations and Partner Catalog
- update integrations in sidenav to link to updated /partners/catalog/**
listings

<img width="1520" height="696" alt="Screenshot 2026-07-15 at 12 54 47"
src="https://github.com/user-attachments/assets/9f5a2794-4536-4299-97df-9732d3d75b4c"
/>

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

* **New Features**
* Added a Partner Catalog experience with search, category filters,
official-partner toggle, responsive filtering (sidebar + bottom sheet),
grid/list views, and featured partners.
* Added Partner Catalog detail pages with tabbed listings, MDX-rendered
content, image gallery with zoom overlay, and “add/install” actions.
* **Improvements**
* Updated “Become a Partner” layout and form support for prefilled
values and checkbox-group fields (including validation).
* Updated navigation/footer/docs and partner tile links to use Partner
Catalog routes; expanded redirects from legacy integrations paths.
  * Added public agent-skills discovery manifest.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alan Daniel <stylesshjs@gmail.com>
Co-authored-by: Alex Hall <alex.hall@supabase.io>
Co-authored-by: Miranda Limonczenko <miranda.limonczenko@supabase.io>
This commit is contained in:
authored and GitHub committed 2026-07-15 11:10:51 +00:00
1 parent 1b1b1ea0e0
commit d23f86021a
41 files changed
+1890 -920

No files matched your search

+8 -4
View File
@@ -7,16 +7,20 @@ NEXT_PUBLIC_BASE_PATH="/docs"
# Whether to enable features available only on Supabase's own hosted services
# Setting this to true requires certain secret keys
NEXT_PUBLIC_IS_PLATFORM="false"
NEXT_PUBLIC_IS_PLATFORM="true"
# Base URL for the hosted MCP server. Overrides the DEFAULT_MCP_URL_PLATFORM
# fallback in @ui-patterns/McpUrlBuilder.
NEXT_PUBLIC_MCP_URL="http://localhost:8080/mcp"
# Supabase project containing integration information
# Supabase project containing integration information (legacy)
NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co"
NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M"
# Supabase project backing the Partner Catalog / Dashboard Integrations Marketplace
NEXT_PUBLIC_MARKETPLACE_API_URL="https://fgxbxpvumhvzrhqngsyu.supabase.co"
NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6"
# Supabase project containing docs content information
NEXT_PUBLIC_SUPABASE_URL="https://xguihxuzqibwxjnimxev.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTgzNzc1MTgsImV4cCI6MjAzMzk1MzUxOH0.aIqjQ9V7djMxYit-DT1fYNV_VWMHSqldh_18XfX2_BE"
NEXT_PUBLIC_MARKETPLACE_API_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/"
NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ"
+77 -16
View File
@@ -1,9 +1,14 @@
import { IS_PLATFORM } from 'common'
import { unstable_cache } from 'next/cache'
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import Layout from '~/layouts/guides'
import { supabaseMisc } from '~/lib/supabaseMisc'
import { IS_PLATFORM } from 'common'
import {
createMarketplaceClient,
SUPABASE_LISTING_OVERRIDES,
SUPABASE_PARTNER_SLUG,
type CatalogListing,
} from 'common/marketplace-client'
import { unstable_cache } from 'next/cache'
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const additionalNavItems = { integrations: await getPartners() }
@@ -16,26 +21,82 @@ export default async function IntegrationsLayout({ children }: { children: React
const getPartners = unstable_cache(getPartnersImpl, [], {
tags: [REVALIDATION_TAGS.PARTNERS],
})
async function getPartnersImpl() {
if (!IS_PLATFORM) return []
const { data, error } = await supabaseMisc()
.from('partners')
.select('slug, title')
.eq('approved', true)
.eq('type', 'technology')
.order('title')
const marketplaceClient = createMarketplaceClient()
const [{ data: partners, error }, { data: listings }] = await Promise.all([
marketplaceClient.from('partners').select('slug, name, type'),
marketplaceClient.from('catalog_listings').select('*'),
])
if (error) {
console.error(new Error('Error fetching partners', { cause: error }))
}
const partnerNavItems = (data ?? []).map(
(partner) =>
({
name: partner.title,
url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`,
}) as Partial<NavMenuSection>
)
// Only "technology" partners belong in this list, not agencies/experts.
const technologyPartnerSlugs = new Set<string>()
const partnerNameBySlug = new Map<string, string>()
for (const partner of partners ?? []) {
if (!partner.slug) continue
partnerNameBySlug.set(partner.slug, partner.name ?? partner.slug)
if (partner.type === 'technology') technologyPartnerSlugs.add(partner.slug)
}
// Supabase-owned listings still get their own nav item, at their clean catalog page slug.
const supabaseOwnedNavItems: Partial<NavMenuSection>[] = []
const listingsByPartnerSlug = new Map<string, CatalogListing[]>()
for (const listing of listings ?? []) {
if (!listing.slug) continue
const override = SUPABASE_LISTING_OVERRIDES[listing.slug]
if (override) {
supabaseOwnedNavItems.push({
name: listing.title || override.name,
url: `https://supabase.com/partners/catalog/${override.slug}` as `https://${string}`,
})
continue
}
const partnerSlug = listing.partner_slug
if (
!partnerSlug ||
partnerSlug === SUPABASE_PARTNER_SLUG ||
!technologyPartnerSlugs.has(partnerSlug)
) {
continue
}
const partnerListings = listingsByPartnerSlug.get(partnerSlug) ?? []
partnerListings.push(listing)
listingsByPartnerSlug.set(partnerSlug, partnerListings)
}
// One nav item per listing (a partner with multiple listings — e.g. an FDW plus a
// Dashboard Integration — gets one entry per listing), linking straight to the tab
// that listing occupies on its Partner Catalog page.
const partnerNavItems: Partial<NavMenuSection>[] = [
...Array.from(listingsByPartnerSlug.entries()).flatMap(([partnerSlug, partnerListings]) => {
const partnerName = partnerNameBySlug.get(partnerSlug) ?? partnerSlug
const catalogUrl = `https://supabase.com/partners/catalog/${partnerSlug}`
// Show only listing name if partner has one listing
if (partnerListings.length === 1) {
const listingName = partnerListings[0].title || partnerName
return [{ name: listingName, url: catalogUrl as `https://${string}` }]
}
// otherwise show listing name (or slug if no title) plus partner name.
return partnerListings.map((listing) => ({
name: `${listing.title || listing.slug} (${partnerName})`,
url: `${catalogUrl}?tab=${listing.slug}` as `https://${string}`,
}))
}),
...supabaseOwnedNavItems,
]
partnerNavItems.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
return partnerNavItems
}
@@ -146,8 +146,8 @@ const navCommands = [
},
{
id: 'nav-integrations',
name: 'Go to Integrations',
route: 'https://supabase.com/partners/integrations',
name: 'Go to Partner Catalog',
route: 'https://supabase.com/partners/catalog',
icon: () => <ArrowRight />,
enabled: isFeatureEnabled('integrations:partners'),
},
@@ -3233,12 +3233,16 @@ export const integrations: NavMenuConstant = {
url: '/guides/integrations',
},
{
name: 'Vercel Marketplace',
url: '/guides/integrations/vercel-marketplace',
name: 'Dashboard Integrations',
url: '/guides/integrations#dashboard-integrations',
},
{
name: 'Supabase Marketplace',
url: '/guides/integrations/supabase-marketplace',
name: 'Partner Catalog',
url: '/guides/integrations/partner-catalog',
},
{
name: 'Vercel Marketplace',
url: '/guides/integrations/vercel-marketplace',
},
{
name: 'Build Your Own',
+8 -4
View File
@@ -4,10 +4,14 @@ title: Integrations
Supabase integrates with many of your favorite third-party services.
## Dashboard Integrations
Install and manage extensions, wrappers, and Postgres Modules directly into your project in a couple of clicks. [Browse Dashboard Integrations](/dashboard/project/_/integrations)
## Partner Catalog
Browse a curated list of Partners who offer one-click integrations, guides or other ways to extend your Supabase project. [Browse the Partner Catalog](/partners/catalog)
## Vercel Marketplace
Create and manage your Supabase projects directly through Vercel. [Get started with Vercel](/docs/guides/integrations/vercel-marketplace).
## Supabase Marketplace
Browse tools for extending your Supabase project. [Browse the Supabase Marketplace](/partners/integrations).
@@ -1,13 +1,12 @@
---
id: 'integrations'
title: 'Supabase Marketplace'
title: 'Partner Catalog'
description: 'Integrations and Partners'
---
The Supabase Marketplace brings together all the tools you need to extend your Supabase project. This includes:
The [Partner Catalog](/partners/catalog) is Supabase's public directory of third-party integrations. It lists tools that extend your Supabase project. These tools cover Auth, Caching, Hosting, and Low-code categories.
- [Experts](/partners/experts) - partners to help you build and support your Supabase project.
- [Integrations](/partners/integrations) - extend your projects with external Auth, Caching, Hosting, and Low-code tools.
The Partner Catalog is different from [Dashboard Integrations](/guides/integrations#dashboard-integrations). You install Dashboard Integrations directly from a project in the Supabase Dashboard.
## Build an integration
@@ -20,12 +19,12 @@ Supabase provides several integration points:
## List your integration
[Apply to the Partners program](/partners/integrations#become-a-partner) to list your integration in the Partners marketplace and in the Supabase docs.
[Apply to the Partners program](/partners/catalog#become-a-partner) to list your integration in the Partner Catalog and in the Supabase docs.
Integrations are assessed on the following criteria:
- **Business viability**
While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the marketplace.
While we welcome everyone to built an integration, we only list companies that are deemed to be long-term viable. This includes an official business registration and bank account, meaningful revenue, or Venture Capital backing. We require this criteria to ensure the health of the catalog.
- **Compliance**
Integrations should not infringe on the Supabase brand/trademark. In short, you cannot use "Supabase" in the name. As the listing appears on the Supabase domain, we don't want to mislead developers into thinking that an integration is an official product.
- **Service Level Agreements**
-14
View File
@@ -1,14 +0,0 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
let _supabaseMisc: SupabaseClient
export function supabaseMisc() {
if (!_supabaseMisc) {
_supabaseMisc = createClient(
process.env.NEXT_PUBLIC_MISC_URL!,
process.env.NEXT_PUBLIC_MISC_ANON_KEY!
)
}
return _supabaseMisc
}
+2
View File
@@ -24,6 +24,8 @@
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"NEXT_PUBLIC_MISC_URL",
"NEXT_PUBLIC_MISC_ANON_KEY",
"NEXT_PUBLIC_MARKETPLACE_API_URL",
"NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY",
"NODE_ENV",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_BASE_PATH",
@@ -1,26 +1,4 @@
import {
BadgeCheck,
BarChart3,
Boxes,
Cable,
Cpu,
CreditCard,
Database,
Fingerprint,
KeyRound,
Mail,
MessageSquare,
MousePointerClick,
Package2,
Plug,
Server,
ShieldCheck,
Users,
Webhook,
Wrench,
Zap,
type LucideIcon,
} from 'lucide-react'
import { BadgeCheck, Cable, Database, Package2, Plug, Users, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { Badge, cn, IconPartners } from 'ui'
@@ -29,6 +7,8 @@ import type {
MarketplaceSource,
} from '@/components/interfaces/Integrations/Landing/Integrations.constants'
export { getCategoryIcon, CATEGORY_ICONS } from 'common/marketplace-categories'
export type { MarketplaceSource } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
// Defines featured integrations and their order in the featured hero
@@ -47,29 +27,6 @@ export const INTEGRATION_TYPES: Array<{
{ key: 'wrapper', label: 'Wrapper', icon: Cable },
]
// Lucide icon per marketplace category slug. New categories fall back to a
// neutral icon (Boxes) so nothing breaks when the marketplaceDB is updated.
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
observability: BarChart3,
security: ShieldCheck,
billing: CreditCard,
secrets: KeyRound,
email: Mail,
wrappers: Database,
ai: Cpu,
ai_vectors: Cpu,
storage: Package2,
postgres_extension: Database,
wrapper: Cable,
devtools: Wrench,
auth: Fingerprint,
'low-code': MousePointerClick,
'data-platform': Server,
api: Webhook,
'caching-offline-first': Zap,
messaging: MessageSquare,
}
export const FEATURED_CATEGORIES: Array<{ slug: string; name: string }> = [
{ slug: 'observability', name: 'Observability' },
{ slug: 'security', name: 'Security' },
@@ -87,11 +44,6 @@ export const EXCLUDED_CATEGORY_SLUGS = new Set([
'app-templates',
])
export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => {
if (!slug) return Boxes
return CATEGORY_ICONS[slug] ?? Boxes
}
export const formatCategoryLabel = (
slug: string | null | undefined,
categoryOptions?: Array<{ slug: string; name: string }>
@@ -229,7 +229,7 @@ export const MarketplaceIndex = () => {
</PageHeaderDescription>
</PageHeaderSummary>
<div className="flex shrink-0 items-center gap-2">
<DocsButton href={`${DOCS_URL}/guides/integrations/supabase-marketplace`} />
<DocsButton href={`${DOCS_URL}/guides/integrations/partner-catalog`} />
</div>
</PageHeaderMeta>
</PageHeader>
+21
View File
@@ -0,0 +1,21 @@
import supabase from '~/lib/supabaseMisc'
import { notFound, redirect } from 'next/navigation'
type Params = { slug: string }
export const dynamic = 'force-dynamic'
export default async function PartnerLegacyPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const { data: partner } = await supabase
.from('partners')
.select('slug, type')
.eq('approved', true)
.eq('slug', slug)
.single()
if (!partner || partner.type === 'expert') notFound()
redirect(`/partners/catalog/${partner.slug}`)
}
@@ -0,0 +1,458 @@
'use client'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import Panel from '~/components/Panel'
import { searchCatalogPartners } from '~/lib/marketplaceDb'
import type { Partner } from '~/types/partners'
import { getCategoryIcon } from 'common/marketplace-categories'
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
import { ArrowRight, ArrowUpRight, Filter, LayoutGrid, List, Loader, Search } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import {
parseAsArrayOf,
parseAsBoolean,
parseAsString,
parseAsStringEnum,
useQueryStates,
} from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Checkbox,
cn,
InputGroup,
InputGroupAddon,
InputGroupInput,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from 'ui'
import { useDebounce } from 'use-debounce'
interface Props {
initialPartners: Partner[]
metaTitle: string
metaDescription: string
}
type ViewMode = 'grid' | 'list'
const OFFICIAL_PARTNER_SLUGS = new Set(['grafana', 'stripe', 'aikido', 'doppler', 'resend'])
const PARTNERS_PAGE_SIZE = 24
export default function IntegrationsContent({
initialPartners,
metaTitle,
metaDescription,
}: Props) {
const [partners, setPartners] = useState(initialPartners)
// Deduplicate by slug, keep name for display, sort alphabetically.
const allCategories = Array.from(
new Map(initialPartners?.flatMap((p) => p.categories).map((c) => [c.slug, c]) ?? []).values()
).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
const [{ q: search, cat: selectedCategories, partner: partnerOnly, view: viewMode }, setFilters] =
useQueryStates(
{
q: parseAsString.withDefault(''),
cat: parseAsArrayOf(parseAsString).withDefault([]),
partner: parseAsBoolean.withDefault(false),
view: parseAsStringEnum<ViewMode>(['grid', 'list']).withDefault('grid'),
},
{ history: 'replace' }
)
const [debouncedSearchTerm] = useDebounce(search, 300)
const [isSearching, setIsSearching] = useState(false)
const searchIdRef = useRef(0)
useEffect(() => {
if (debouncedSearchTerm.trim() === '') {
setIsSearching(false)
setPartners(initialPartners)
return
}
setIsSearching(true)
const currentSearchId = ++searchIdRef.current
searchCatalogPartners(debouncedSearchTerm)
.then((results) => {
if (currentSearchId === searchIdRef.current) setPartners(results ?? [])
})
.catch(() => {
if (currentSearchId === searchIdRef.current) setPartners([])
})
.finally(() => {
if (currentSearchId === searchIdRef.current) setIsSearching(false)
})
}, [debouncedSearchTerm, initialPartners])
// selectedCategories holds slugs; toggle by slug
const handleCategoryChange = (slug: string) =>
setFilters({
cat: selectedCategories.includes(slug)
? selectedCategories.filter((s) => s !== slug)
: [...selectedCategories, slug],
})
const HAS_ACTIVE_FILTERS = search.trim() !== '' || selectedCategories.length > 0 || partnerOnly
const activeFilterCount = selectedCategories.length + (partnerOnly ? 1 : 0)
const filtered = useMemo(() => {
const categoryFiltered =
selectedCategories.length > 0
? partners.filter((p) => p.categories.some((c) => selectedCategories.includes(c.slug)))
: partners
return partnerOnly
? categoryFiltered.filter((p) => OFFICIAL_PARTNER_SLUGS.has(p.slug))
: categoryFiltered
}, [partners, selectedCategories, partnerOnly])
const featuredPartners = useMemo(
() => filtered.filter((p) => p.featured).sort((a, b) => a.title.localeCompare(b.title)),
[filtered]
)
const listPartners = useMemo(
() =>
HAS_ACTIVE_FILTERS
? [...filtered.filter((p) => p.featured), ...filtered.filter((p) => !p.featured)]
: filtered.filter((p) => !p.featured),
[filtered, HAS_ACTIVE_FILTERS]
)
const showFeatured = !HAS_ACTIVE_FILTERS && featuredPartners.length > 0
// Only render the first page of the (already-fetched, in-memory) partner list up front,
// then reveal more as the sentinel below the grid/list scrolls into view — avoids mounting
// every partner card (and its logo <Image>) on initial load.
const initialVisiblePartners = useMemo(
() => listPartners.slice(0, PARTNERS_PAGE_SIZE),
[listPartners]
)
const fetchMoreVisiblePartners = useCallback(
async (offset: number, limit: number) => listPartners.slice(offset, offset + limit),
[listPartners]
)
const {
items: visiblePartners,
hasMore: hasMorePartners,
loadMoreRef: partnersLoadMoreRef,
} = useInfiniteScrollWithFetch({
initialItems: initialVisiblePartners,
totalItems: listPartners.length,
pageSize: PARTNERS_PAGE_SIZE,
fetchMore: fetchMoreVisiblePartners,
})
// Shared filter UI — rendered in both the desktop sidebar and the mobile bottom sheet.
// Uses wrapping <label> elements (no id/htmlFor) to avoid duplicate HTML IDs in the DOM.
const filtersPanel = (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
<h2 className="text-xs font-mono uppercase text-foreground-lighter">Categories</h2>
{allCategories.map((category) => {
const Icon = getCategoryIcon(category.slug)
return (
<label
key={category.slug}
className="flex cursor-pointer items-center justify-between gap-3 text-foreground-light transition-colors hover:text-foreground"
>
<span className="flex flex-1 items-center gap-2 text-sm text-left">
<Icon size={13} className="shrink-0 text-foreground-lighter" />
{category.name}
</span>
<Checkbox
checked={selectedCategories.includes(category.slug)}
onCheckedChange={() => handleCategoryChange(category.slug)}
className="[&_input]:m-0"
/>
</label>
)
})}
</div>
{HAS_ACTIVE_FILTERS && (
<Button
block
variant="dashed"
onClick={() => setFilters({ cat: [], partner: false, q: '' })}
>
Clear all filters
</Button>
)}
</div>
)
return (
<DefaultLayout>
<SectionContainer>
<div className="flex flex-col gap-3 mb-8">
<span className="text-brand font-mono uppercase tracking-widest text-sm">
Partner Catalog
</span>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div className="flex flex-col gap-3">
<h1 className="h1 mb-0!">{metaTitle}</h1>
<p className="text-foreground-lighter text-xl max-w-2xl text-balance">
{metaDescription}
</p>
</div>
<Button asChild size="tiny" className="shrink-0">
<Link href="https://forms.supabase.com/partner">Apply as a Partner</Link>
</Button>
</div>
</div>
<div className="relative grid md:grid-cols-4 md:gap-4">
{/* Left sidebar — sticky search + filters */}
<div className="relative w-full h-full">
<div className="mb-4 flex flex-col gap-4 sticky top-20">
{/* Search bar + mobile filter trigger */}
<div className="flex gap-2">
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
size="small"
autoComplete="off"
type="search"
placeholder="Search partners"
value={search}
onChange={(e) => setFilters({ q: e.target.value })}
/>
{isSearching && (
<InputGroupAddon align="inline-end">
<Loader size={14} className="animate-spin" />
</InputGroupAddon>
)}
</InputGroup>
{/* Mobile-only: opens bottom sheet with filters */}
<Sheet>
<SheetTrigger asChild>
<Button variant="default" size="small" className="shrink-0 md:hidden flex">
<div className="flex gap-1.5 items-center text-foreground-lighter hover:text-foreground">
<Filter size={14} />
{activeFilterCount > 0 && (
<span className="flex items-center justify-center text-xs text-foreground-light">
{activeFilterCount}
</span>
)}
</div>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="flex h-screen max-h-[85dvh] flex-col">
<SheetHeader className="px-4 py-3.5">
<SheetTitle className="text-sm text-left">Filter partners</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-4">{filtersPanel}</div>
</SheetContent>
</Sheet>
</div>
{/* Desktop-only filter panel */}
<div className="hidden md:block">{filtersPanel}</div>
</div>
</div>
{/* Right: content */}
<div className="md:col-span-3 min-w-0 flex flex-col gap-4 md:gap-6">
{/* Featured partners */}
{showFeatured && (
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Featured</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{featuredPartners.map((p) => (
<Link
key={p.slug}
href={`/partners/catalog/${p.slug}`}
className="group flex h-full flex-col gap-3 rounded-xl border bg-surface-100 p-5 transition-colors hover:bg-surface-200 overflow-hidden"
>
<div className="flex items-center gap-3">
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
<Image
src={p.logo}
alt={p.title}
fill
className="object-cover bg-white rounded-full overflow-hidden p-1"
sizes="40px"
/>
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="text-foreground text-base font-medium tracking-tight">
{p.title}
</h3>
</div>
</div>
<p className="text-foreground-lighter text-sm line-clamp-3 text-pretty">
{p.description}
</p>
<div className="flex flex-wrap items-center gap-1">
{p.categories.map((c) => (
<Badge key={c.name}>{c.name}</Badge>
))}
</div>
</Link>
))}
</div>
</div>
)}
{/* Toolbar: count + view toggle */}
<div className="flex items-center justify-between gap-2">
<span className="text-foreground-muted text-xs">
{listPartners.length} partner{listPartners.length !== 1 ? 's' : ''}
</span>
<div className="flex items-center rounded-lg border border-muted">
<button
title="Grid view"
onClick={() => setFilters({ view: 'grid' })}
className={cn(
'relative flex items-center justify-center w-8 h-8 transition-colors rounded-l-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
viewMode === 'grid'
? 'bg-surface-300 text-foreground'
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
)}
>
<LayoutGrid size={14} />
</button>
<button
title="List view"
onClick={() => setFilters({ view: 'list' })}
className={cn(
'relative flex items-center justify-center w-8 h-8 transition-colors border-l border-muted rounded-r-lg focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:ring-offset-1 focus-visible:ring-offset-background',
viewMode === 'list'
? 'bg-surface-300 text-foreground'
: 'bg-surface-75 text-foreground-muted hover:text-foreground hover:bg-surface-200'
)}
>
<List size={14} />
</button>
</div>
</div>
{/* Grid view */}
{viewMode === 'grid' &&
(listPartners.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
{visiblePartners.map((p) => (
<Link
key={p.slug}
href={`/partners/catalog/${p.slug}`}
className="flex flex-col justify-start items-stretch group cursor-pointer transition rounded-xl focus-visible:ring-2 focus-visible:ring-foreground-lighter outline-hidden outline-0 focus-visible:outline-4 focus-visible:outline-offset-1 focus-visible:outline-foreground-lighter"
>
<Panel
hasActiveOnHover
outerClassName="h-full"
innerClassName="flex md:flex-col gap-3 sm:gap-2 h-full items-start p-2"
>
<div className="relative rounded-lg min-h-[80px] max-h-[80px] md:max-h-[140px] h-full md:h-auto aspect-square md:w-full md:aspect-video! bg-alternative flex items-center justify-center shadow-inner border border-muted overflow-hidden shrink-0 md:shrink">
<Image
src={p.logo}
alt={p.title}
width={60}
height={60}
className="border w-14 h-14 aspect-square p-1 bg-white rounded-full overflow-hidden"
sizes="60px"
/>
</div>
<div className="md:p-2 md:pt-1 flex flex-col h-full md:h-auto grow gap-0.5 md:gap-1.5 justify-center md:justify-start min-w-0">
<h3 className="text-sm md:text-base text-foreground leading-5!">
{p.title}
</h3>
<p className="text-foreground-light text-sm line-clamp-2 flex-1">
{p.description}
</p>
<div className="flex flex-wrap items-center gap-1 mt-1">
{p.categories.map((c) => (
<Badge key={c.name}>{c.name}</Badge>
))}
</div>
</div>
</Panel>
</Link>
))}
</div>
) : (
<p className="text-foreground-lighter text-sm">
No partners found with these filters.
</p>
))}
{/* List view */}
{viewMode === 'list' &&
(listPartners.length ? (
<div className="border bg-background rounded-xl overflow-hidden divide-y">
{visiblePartners.map((p) => (
<Link
key={p.slug}
href={`/partners/catalog/${p.slug}`}
className="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-surface-100"
>
<div className="relative size-10 shrink-0 rounded-full overflow-hidden bg-muted">
<Image
src={p.logo}
alt={p.title}
fill
className="object-cover bg-white rounded-full overflow-hidden p-1"
sizes="40px"
/>
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-foreground text-sm font-medium tracking-tight">
{p.title}
</span>
<div className="flex items-center gap-1">
{p.categories.map((c) => (
<Badge key={c.name}>{c.name}</Badge>
))}
</div>
</div>
<p className="text-foreground-lighter text-sm truncate">{p.description}</p>
</div>
<ArrowUpRight
size={16}
className="shrink-0 text-foreground-lighter transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
/>
</Link>
))}
</div>
) : (
<p className="text-foreground-lighter text-sm">
No partners found with these filters.
</p>
))}
{hasMorePartners && (
<div
ref={partnersLoadMoreRef}
className="flex justify-center py-4"
aria-hidden="true"
>
<Loader size={16} className="animate-spin text-foreground-lighter" />
</div>
)}
</div>
</div>
</SectionContainer>
<div className="border-t bg-background">
<div
id="become-a-partner"
className="mx-auto max-w-2xl flex flex-col items-center gap-6 py-32 px-6 text-center"
>
<h2 className="h2 text-balance">Interested in partnering with Supabase?</h2>
<Button asChild size="medium" iconRight={<ArrowRight />}>
<Link href="https://forms.supabase.com/partner">Apply as a Partner</Link>
</Button>
</div>
</div>
</DefaultLayout>
)
}
@@ -0,0 +1,409 @@
'use client'
import 'swiper/css'
import { CH } from '@code-hike/mdx/components'
import DefaultLayout from '~/components/Layouts/Default'
import { type ListingDetail, type Partner } from '~/types/partners'
import { useBreakpoint } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowUpRight, ChevronLeft, ExternalLink } from 'lucide-react'
import type { SerializeResult as MDXRemoteSerializeResult } from 'next-mdx-remote-client'
import { MDXClient } from 'next-mdx-remote-client/csr'
import Image from 'next/image'
import Link from 'next/link'
import { useQueryState } from 'nuqs'
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Button, cn } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import SectionContainerWithCn from '@/components/Layouts/SectionContainerWithCn'
function mdxComponents(callback: Dispatch<SetStateAction<string | null>>) {
return {
CH,
Admonition,
img: (
props: React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
) => <img {...props} onClick={() => callback(props.src!.toString())} />,
}
}
type Props = {
partner: Partner
serializedListings: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>[]
}
export default function PartnerCatalogDetail({ partner, serializedListings }: Props) {
const [focusedImage, setFocusedImage] = useState<string | null>(null)
const isNarrow = useBreakpoint('lg')
const tabsRef = useRef<HTMLDivElement>(null)
const [showStickyBar, setShowStickyBar] = useState(false)
useEffect(() => {
const tabsEl = tabsRef.current
if (!tabsEl) return
// Sticky bar sticks at top-16 (4rem), so show it once the tabs scroll past that point.
const handleScroll = () => setShowStickyBar(tabsEl.getBoundingClientRect().bottom <= 64)
handleScroll()
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Computed before the hook so the first slug can seed the default value.
const allListings: ListingDetail[] = partner.listings?.length
? partner.listings
: [
{
slug: partner.slug,
label: 'Overview',
publishedInMarketplace: false,
content: partner.content,
installUrl: partner.installUrl,
docsUrl: partner.docsUrl,
images: partner.images,
youtubeId: partner.youtubeId,
},
]
const [activeSlug, setActiveSlug] = useQueryState('tab', {
defaultValue: allListings[0]?.slug ?? '',
history: 'replace',
})
const activeListing = allListings.find((l) => l.slug === activeSlug) ?? allListings[0]
const activeTabIndex = allListings.indexOf(activeListing)
const activeOverview = serializedListings[activeTabIndex] ?? serializedListings[0]
// Marketplace integrations link to the Supabase dashboard; others use installUrl if provided.
const INSTALL_HREF_BLOCKLIST = ['aikido-security', 'stripe-sync-engine']
const installHref = INSTALL_HREF_BLOCKLIST.includes(partner.slug)
? undefined
: activeListing.publishedInMarketplace
? activeListing.dashboardUrl
: activeListing.installUrl
return (
<>
<AnimatePresence>
{focusedImage && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: [0.32, 0.72, 0, 1] }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-8 cursor-zoom-out"
onClick={() => setFocusedImage(null)}
>
<motion.div
layoutId={`partner-image-${focusedImage}`}
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
className="relative w-full aspect-16/8 rounded-md border bg-muted"
onClick={(e) => e.stopPropagation()}
>
<Image
src={focusedImage}
alt={partner.title}
fill
sizes="80vw"
className="rounded-md object-cover"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<DefaultLayout>
<div className="relative h-auto w-full">
<SectionContainerWithCn height="narrow" className="col-span-12 lg:col-span-2">
{/* Back button */}
<Link
href="/partners/catalog"
className="text-foreground-lighter hover:text-foreground text-sm flex cursor-pointer items-center transition-colors gap-1"
>
<ChevronLeft width={14} height={14} />
Partner Catalog
</Link>
{/* Partner header */}
<div className="flex mt-6 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center space-x-4">
<Image
width={56}
height={56}
className="bg-white p-1 shrink-0 h-14 w-14 rounded-full border"
src={partner.logo}
alt={partner.title}
/>
<h1 className="h1 mb-0!">{partner.title}</h1>
</div>
{installHref && (
<Button asChild size="medium" iconRight={<ArrowUpRight strokeWidth={1.5} />}>
<a href={installHref} target="_blank" rel="noreferrer">
{activeListing.publishedInMarketplace
? 'Install integration'
: 'Add integration'}
</a>
</Button>
)}
</div>
</SectionContainerWithCn>
<div className="border-b">
<SectionContainerWithCn
height="none"
className="overflow-x-scroll overflow-y-hidden"
ref={tabsRef}
>
{/* Listings tabs */}
<div className="flex">
{allListings.map((listing, i) => (
<button
key={listing.slug}
type="button"
onClick={() => {
setActiveSlug(listing.slug)
setFocusedImage(null)
}}
className={cn(
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap hover:cursor-pointer',
activeTabIndex === i
? 'border-foreground text-foreground'
: 'border-transparent text-foreground-lighter hover:text-foreground'
)}
>
{listing.label}
</button>
))}
</div>
</SectionContainerWithCn>
</div>
{/* Images for active listing */}
{(activeListing.images?.length ?? 0) > 0 && (
<div className="bg-linear-to-t overflow-hidden from-background-alternative to-background border-b py-6 [&_.swiper]:overflow-visible! [&_.swiper-wrapper]:overflow-visible!">
<SectionContainerWithCn height="none" className="">
<Swiper
initialSlide={0}
spaceBetween={20}
slidesPerView={4}
speed={300}
grabCursor
centeredSlides={false}
centerInsufficientSlides={false}
breakpoints={{
320: { slidesPerView: 1.45, centeredSlides: false, spaceBetween: 10 },
720: { slidesPerView: 1.75, centeredSlides: false, spaceBetween: 10 },
920: { slidesPerView: 2.5, centeredSlides: false },
1024: { slidesPerView: 3 },
1280: { slidesPerView: 4 },
}}
>
{activeListing.images?.map((image, i) => (
<SwiperSlide key={i}>
<div className="relative aspect-16/8">
<AnimatePresence>
{focusedImage !== image && (
<motion.div
key={`thumb-${image}`}
layoutId={`partner-image-${image}`}
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
className="absolute inset-0 cursor-zoom-in rounded-md border bg-muted"
onClick={() => setFocusedImage(image)}
>
<Image
placeholder="blur"
blurDataURL="/images/blur.png"
fill
sizes="(min-width: 1280px) 25vw, (min-width: 1024px) 33vw, (min-width: 920px) 40vw, (min-width: 720px) 60vw, 90vw"
src={image}
alt={partner.title}
className="rounded-md object-cover"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</SwiperSlide>
))}
</Swiper>
</SectionContainerWithCn>
</div>
)}
{/* Main content */}
<SectionContainerWithCn height="narrow">
<div className="grid gap-y-12 lg:grid-cols-8 lg:gap-x-20">
{isNarrow && <PartnerDetails partner={partner} activeListing={activeListing} />}
<div className="lg:col-span-5 overflow-hidden">
<h2
className="text-foreground"
style={{ fontSize: '1.5rem', marginBottom: '1rem' }}
>
Overview
</h2>
<div className="prose">
{'error' in activeOverview ? (
<p>Error rendering integration page: {activeOverview.error.message}</p>
) : (
// @ts-expect-error: CodeHike puts its components under the CH namespace
<MDXClient {...activeOverview} components={mdxComponents(setFocusedImage)} />
)}
</div>
</div>
{!isNarrow && <PartnerDetails partner={partner} activeListing={activeListing} />}
</div>
</SectionContainerWithCn>
<div className="absolute h-full w-full inset-0 z-20 pointer-events-none">
<SectionContainerWithCn
height="none"
className={cn(
'transition-all bg-background/90 -translate-y-3/4 dark:bg-background/95 backdrop-blur-xs border-b sticky top-16 z-20 flex justify-between items-center gap-4 py-2',
showStickyBar
? 'opacity-100 pointer-events-auto translate-y-0'
: 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center space-x-2">
<Image
width={56}
height={56}
className="hidden sm:block bg-white p-0.5 shrink-0 h-6 w-6 rounded-full border"
src={partner.logo}
alt={partner.title}
/>
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-1">
<span className="sm:text-lg truncate">{partner.title}</span>
{activeListing && (
<>
<span className="text-foreground-lighter text-sm hidden sm:inline">—</span>
<span className="text-foreground-lighter text-sm truncate">
{activeListing.label}
</span>
</>
)}
</div>
</div>
{installHref && (
<Button asChild>
<a href={installHref} target="_blank" rel="noreferrer">
{activeListing.publishedInMarketplace
? 'Install integration'
: 'Add integration'}
</a>
</Button>
)}
</SectionContainerWithCn>
</div>
</div>
<div className="border-t bg-background">
<SectionContainerWithCn className="mx-auto max-w-2xl flex flex-col items-center gap-6 py-24 px-6 text-center">
<h2 className="h2 text-balance">Interested in partnering with Supabase?</h2>
<Button asChild size="medium">
<Link href="/partners#become-a-partner">Become a partner</Link>
</Button>
</SectionContainerWithCn>
</div>
</DefaultLayout>
</>
)
}
function PartnerDetails({
partner,
activeListing,
}: {
partner: Partner
activeListing: ListingDetail
}) {
return (
<div className="lg:col-span-3">
<div className="sticky top-32 flex flex-col gap-4">
<h2 className="text-foreground font-mono uppercase tracking-wide text-xs">Details</h2>
{activeListing.youtubeId && (
<ExpandableVideo
videoId={activeListing.youtubeId}
imgOverlayText="Watch an introductory video"
triggerContainerClassName="w-full"
/>
)}
<div className="text-foreground divide-y text-sm">
{partner.type === 'technology' && (
<div className="flex items-center justify-between py-1.5 md:py-2">
<span className="text-foreground-lighter">Developer</span>
<span className="text-foreground">{partner.builtBy}</span>
</div>
)}
{partner.categories.map((category) => (
<div key={category.slug} className="flex items-center justify-between py-1.5 md:py-2">
<span className="text-lighter">Category</span>
<Link
href={`/partners/catalog?cat=${encodeURIComponent(category.slug)}`}
className="text-brand-link hover:underline transition-colors"
>
{category.name}
</Link>
</div>
))}
{partner.websiteUrl && (
<div className="flex items-center justify-between py-1.5 md:py-2">
<span className="text-foreground-lighter">Website</span>
<a
href={
partner.websiteUrl.startsWith('http')
? partner.websiteUrl
: `https://${partner.websiteUrl}`
}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
{(() => {
try {
const url = partner.websiteUrl.startsWith('http')
? partner.websiteUrl
: `https://${partner.websiteUrl}`
return new URL(url).host
} catch {
return partner.websiteUrl
}
})()}
</a>
</div>
)}
{partner.type === 'technology' && activeListing.docsUrl && (
<div className="flex items-center justify-between py-1.5 md:py-2">
<span className="text-foreground-lighter">Documentation</span>
<a
href={activeListing.docsUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
<span className="flex items-center space-x-1">
<span>Learn</span>
<ExternalLink width={14} height={14} />
</span>
</a>
</div>
)}
</div>
<p className="text-foreground-light text-sm">
Third-party integrations and docs are managed by Supabase partners.
</p>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
import { getCatalogPartner, listCatalogPartnerSlugs } from '~/lib/marketplaceDb'
import type { ListingDetail } from '~/types/partners'
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
import type { Metadata } from 'next'
import { serialize } from 'next-mdx-remote-client/serialize'
import { notFound } from 'next/navigation'
import { cache, Suspense } from 'react'
import remarkGfm from 'remark-gfm'
import PartnerCatalogDetail from './PartnerCatalogDetail'
export const revalidate = 1800
type Params = { slug: string }
// Deduplicate the DB call across generateMetadata and the page component.
const getPartner = cache(getCatalogPartner)
export async function generateStaticParams() {
const slugs = await listCatalogPartnerSlugs()
return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params
const partner = await getPartner(slug)
if (!partner) return {}
return {
title: `${partner.title} | Works With Supabase`,
description: partner.description,
openGraph: {
title: `${partner.title} | Works With Supabase`,
description: partner.description,
url: `https://supabase.com/partners/catalog/${partner.slug}`,
images: [{ url: partner.images?.[0] ?? partner.logo }],
},
}
}
export default async function PartnerCatalogPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const partner = await getPartner(slug)
if (!partner) notFound()
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
showCopyButton: true,
skipLanguages: [],
autoImport: false,
}
const listingsForTabs: ListingDetail[] = partner.listings?.length
? partner.listings
: [
{
slug: partner.slug,
label: 'Overview',
publishedInMarketplace: false,
content: partner.content,
installUrl: partner.installUrl,
docsUrl: partner.docsUrl,
images: partner.images,
youtubeId: partner.youtubeId,
},
]
const serializedListings = await Promise.all(
listingsForTabs.map((listing) =>
serialize({
source: listing.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
},
},
})
)
)
return (
<Suspense>
<PartnerCatalogDetail partner={partner} serializedListings={serializedListings} />
</Suspense>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { listCatalogPartners } from '~/lib/marketplaceDb'
import type { Metadata } from 'next'
import { Suspense } from 'react'
import IntegrationsContent from './IntegrationsContent'
export const revalidate = 1800 // 30 minutes
const META_TITLE = 'Partners building with Supabase'
const META_DESCRIPTION =
'Browse companies that build on Supabase, integrate with Supabase, or both.'
export const metadata: Metadata = {
title: META_TITLE,
description: META_DESCRIPTION,
openGraph: {
title: META_TITLE,
description: META_DESCRIPTION,
url: 'https://supabase.com/partners/catalog',
images: [{ url: 'https://supabase.com/images/og/integrations.png' }],
},
}
export default async function IntegrationPartnersPage() {
const partners = await listCatalogPartners()
return (
<Suspense>
<IntegrationsContent
initialPartners={partners}
metaTitle={META_TITLE}
metaDescription={META_DESCRIPTION}
/>
</Suspense>
)
}
+25 -22
View File
@@ -12,6 +12,7 @@ import {
import { WwwCommandMenu } from 'components/CommandMenu'
import { DevToolbar, DevToolbarProvider } from 'dev-tools'
import { API_URL } from 'lib/constants'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { TooltipProvider } from 'ui'
import { CommandProvider } from 'ui-patterns/CommandMenu'
import { useConsentToast } from 'ui-patterns/consent'
@@ -23,28 +24,30 @@ function Providers({ children }: { children: React.ReactNode }) {
const { hasAcceptedConsent } = useConsentToast()
return (
<AuthProvider>
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
<DevToolbarProvider apiUrl={API_URL}>
<ThemeProvider>
<TooltipProvider delayDuration={0}>
<CommandProvider>
<TelemetryTagManager />
<Toaster />
{children}
<WwwCommandMenu />
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
<DevToolbar />
</CommandProvider>
</TooltipProvider>
</ThemeProvider>
</DevToolbarProvider>
</FeatureFlagProvider>
</AuthProvider>
<NuqsAdapter>
<AuthProvider>
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
<DevToolbarProvider apiUrl={API_URL}>
<ThemeProvider>
<TooltipProvider delayDuration={0}>
<CommandProvider>
<TelemetryTagManager />
<Toaster />
{children}
<WwwCommandMenu />
<PageTelemetry
API_URL={API_URL}
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
/>
<DevToolbar />
</CommandProvider>
</TooltipProvider>
</ThemeProvider>
</DevToolbarProvider>
</FeatureFlagProvider>
</AuthProvider>
</NuqsAdapter>
)
}
@@ -0,0 +1,36 @@
import { cn } from 'ui'
export interface SectionEyebrowProps {
eyebrow?: string
title: string | React.ReactNode
description?: string
align?: 'left' | 'center'
className?: string
}
const SectionHeading = ({
eyebrow,
title,
description,
align = 'left',
className,
}: SectionEyebrowProps) => (
<div
className={cn(
align === 'center' ? 'flex flex-col items-center text-center gap-3' : 'flex flex-col gap-3',
className
)}
>
{eyebrow && (
<span className="text-brand font-mono text-sm uppercase tracking-wide">{eyebrow}</span>
)}
<h2 className="text-foreground text-3xl md:text-4xl tracking-tight max-w-[35ch] text-balance">
{title}
</h2>
{description && (
<p className="text-foreground-lighter text-lg max-w-[56ch] text-pretty">{description}</p>
)}
</div>
)
export default SectionHeading
+1 -1
View File
@@ -82,7 +82,7 @@ const Panel = ({
<Component
ref={outerRef}
className={cn(
'group/panel relative rounded-lg md:rounded-xl p-px bg-surface-75 bg-linear-to-b from-border to-border/50 dark:to-surface-100 transition-all hover:shadow-md flex items-center justify-center',
'group/panel relative rounded-lg md:rounded-xl p-px bg-surface-75 bg-linear-to-b from-border to-border/50 dark:to-surface-100 transition-all hover:shadow-md',
!trackCursor && hasActiveOnHover
? activeColor === 'brand'
? 'hover:bg-none hover:bg-brand!'
@@ -1,17 +0,0 @@
import { Button } from 'ui'
import Link from 'next/link'
export default function BecomeAPartner() {
return (
<div className="border-t">
<div id="become-a-partner" className="mx-auto max-w-2xl space-y-12 py-32 px-6 text-center">
<h2 className="h2">Ready to work together?</h2>
<Button asChild size="medium" className="text-white">
<Link href="https://forms.supabase.com/partner" as="https://forms.supabase.com/partner">
Become a partner
</Link>
</Button>
</div>
</div>
)
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { Category, Partner } from '~/types/partners'
import type { Listing } from 'common/marketplace-client'
import type { CatalogListing } from 'common/marketplace-client'
import Image from 'next/image'
import Link from 'next/link'
@@ -36,7 +36,7 @@ export default function TileGrid({
<h2 className="h2">Featured</h2>
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{featuredPartners?.map((p) => (
<Link key={p.slug} href={`/partners/integrations/${p.slug}`}>
<Link key={p.slug} href={`/partners/catalog/${p.slug}`}>
<div
className="
bg-surface-100
@@ -85,7 +85,7 @@ export default function TileGrid({
{!hideCategories && <h2 className="h2">{partnersByCategory[slug].category.name}</h2>}
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{partners.map((p) => (
<Link key={p.slug} href={`/partners/integrations/${p.slug}`}>
<Link key={p.slug} href={`/partners/catalog/${p.slug}`}>
<div
className="
bg-surface-100
@@ -1,10 +1,11 @@
import styles from '~/styles/animations.module.css'
import { PlayCircle } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { Button, cn } from 'ui'
import styles from '~/styles/animations.module.css'
import AnnouncementBadge from '../Announcement/Badge'
import ProductIcon from '../ProductIcon'
import { PlayCircle } from 'lucide-react'
interface Types {
h1: string | React.ReactNode
@@ -23,38 +24,36 @@ interface Types {
cta?: {
label?: string
link: string
icon?: React.ReactNode
}
secondaryCta?: {
label?: string
link: string
icon?: React.ReactNode
}
video?: string
}
const ProductHeaderCentered = (props: Types) => (
<div
className={cn(
'container relative w-full mx-auto px-6 pt-2 pb-0 sm:px-16 xl:px-20',
props.className
)}
>
<div className={cn('container relative w-full mx-auto px-6 sm:px-16 xl:px-20', props.className)}>
<div className="flex flex-col text-center items-center">
{props.image && typeof props.image === 'string' ? (
<div className="relative w-full max-w-[630px] mx-auto z-0 aspect-[2.3/1] -mt-8 -mb-8 md:-mb-12 lg:-mb-12">
<Image
src={props.image}
priority
layout="fill"
objectFit="contain"
objectPosition="top"
alt=""
/>
</div>
) : (
<div className="col-span-12 mt-8 lg:col-span-7 lg:mt-0 xl:col-span-6 xl:col-start-7">
{props.image}
</div>
)}
{props.image &&
(typeof props.image === 'string' ? (
<div className="relative w-full max-w-[630px] mx-auto z-0 aspect-[2.3/1] -mt-8 -mb-8 md:-mb-12 lg:-mb-12">
<Image
src={props.image}
priority
layout="fill"
objectFit="contain"
objectPosition="top"
alt=""
/>
</div>
) : (
<div className="col-span-12 mt-8 lg:col-span-7 lg:mt-0 xl:col-span-6 xl:col-start-7">
{props.image}
</div>
))}
<div className="relative w-full z-10 flex flex-col items-center space-y-2 mx-auto max-w-2xl">
{props.announcement && (
<AnnouncementBadge {...props.announcement} className="pb-4 md:pb-4 z-10" />
@@ -74,15 +73,15 @@ const ProductHeaderCentered = (props: Types) => (
</div>
) : null}
</div>
<div className={cn(styles['appear-from-bottom'])}>
<h1 className="h1 text-3xl md:text-4xl tracking-[-1.5px]" key={`h1`}>
<div className={cn('flex flex-col items-center', styles['appear-from-bottom'])}>
<h1 className="h1 text-3xl md:text-4xl! lg:text-5xl! text-balance" key={`h1`}>
{props.h1}
</h1>
<p className="p text-foreground-light!">{props.subheader}</p>
<p className="p text-foreground-light text-balance">{props.subheader}</p>
</div>
<div className="w-full sm:w-auto flex flex-col items-stretch sm:flex-row pt-2 sm:items-center gap-2">
{props.cta && (
<Button size="medium" className="text-white" asChild>
<Button size="medium" className="text-white" iconRight={props.cta.icon} asChild>
<Link href={props.cta.link} as={props.cta.link}>
{props.cta.label ?? 'Start for free'}
</Link>
@@ -96,7 +95,7 @@ const ProductHeaderCentered = (props: Types) => (
</Button>
)}
{props.secondaryCta && (
<Button variant="default" size="medium" asChild>
<Button variant="default" size="medium" iconRight={props.secondaryCta.icon} asChild>
<Link href={props.secondaryCta.link} as={props.secondaryCta.link}>
{props.secondaryCta.label}
</Link>
+10 -10
View File
@@ -1,4 +1,4 @@
import { Calendar, Pencil, ScrollText } from 'lucide-react'
import { Calendar, Pencil, ScrollText, Store } from 'lucide-react'
import {
IconBriefcase2,
IconDocumentation,
@@ -19,9 +19,10 @@ export const data = {
icon: IconDocumentation,
},
{
text: 'Integrations',
url: '/partners/integrations',
icon: IconIntegrations,
text: 'Contribute',
description: 'How to contribute to Supabase.',
url: '/contribute',
icon: () => <Pencil className="w-4 h-4" />,
},
{
text: 'Supabase UI',
@@ -75,17 +76,16 @@ export const data = {
url: '/open-source',
icon: IconGitHubSolid,
},
{
text: 'Contribute',
description: 'How to contribute to Supabase.',
url: '/contribute',
icon: () => <Pencil className="w-4 h-4" />,
},
{
text: 'Become a Partner',
url: '/partners',
icon: IconPartners,
},
{
text: 'Partner Catalog',
url: '/partners/catalog',
icon: IconIntegrations,
},
{
text: 'Careers',
url: '/careers',
+2 -2
View File
@@ -93,8 +93,8 @@ const footerData = [
url: '/partners',
},
{
text: 'Integrations',
url: '/partners/integrations',
text: 'Partner Catalog',
url: '/partners/catalog',
},
{
text: 'Brand Assets',
+4
View File
@@ -42,6 +42,10 @@ export default {
label: 'Become a Partner',
link: 'https://forms.supabase.com/partner',
},
secondaryCta: {
label: 'Partner Catalog',
link: '/partners/catalog',
},
},
oAuthApp: {
steps: [
+2
View File
@@ -20,6 +20,7 @@ async function generate() {
'!pages/api',
'!pages/404.tsx',
'.next/server/pages/partners/integrations/*.html',
'.next/server/pages/partners/catalog/*.html',
'.next/server/pages/partners/experts/*.html',
'.next/server/pages/features/*.html',
])
@@ -60,6 +61,7 @@ async function generate() {
if (route === '/blog/categories/[category]') return null
if (route === '/partners/experts/[slug]') return null
if (route === '/partners/integrations/[slug]') return null
if (route === '/partners/catalog/[slug]') return null
if (route === '/launch-week/ticket-image') return null
if (route === '/launch-week/tickets/[username]') return null
if (route === '/changelog/[slug]') return null
+430 -94
View File
@@ -1,66 +1,443 @@
import { toPartner as miscDbToPartner, Partner } from '~/types/partners'
import { createMarketplaceClient, fullImageUrl, type Listing } from 'common/marketplace-client'
import {
ListingDetail,
toPartner as miscDbToPartner,
Partner,
type Category,
} from '~/types/partners'
import {
createMarketplaceClient,
fullImageUrl,
SUPABASE_LISTING_OVERRIDES,
SUPABASE_PARTNER_SLUG,
type CatalogListing,
} from 'common/marketplace-client'
import supabase from './supabaseMisc'
// Switch between new Marketplace DB and legacy Supabase Misc DB by updating the environment var
// in the Vercel deployment and redeploying, as that will take effect more quickly than flipping a
// feature flag in ConfigCat and waiting for the revalidate timeout.
const isUseMarketplaceDb =
export const isUseMarketplaceDb =
process.env.NEXT_PUBLIC_INTEGRATIONS_MARKETPLACE_DB?.toLowerCase() === 'true'
const marketplaceClient = createMarketplaceClient()
function toPartner(listing: Listing): Partner {
const {
categories,
featured,
slug,
title,
built_by,
description,
content,
website_url,
documentation_url,
marketplace_url,
listing_logo,
images,
youtube_id,
} = listing
return {
categories,
featured,
// The view definition only returns tech partners
type: 'technology',
slug,
title,
builtBy: built_by,
description,
content,
websiteUrl: website_url,
docsUrl: documentation_url,
installUrl: marketplace_url,
logo: fullImageUrl(listing_logo),
images: images?.map(fullImageUrl) ?? [],
youtubeId: youtube_id,
}
// URL-facing slugs for overridden listings (e.g. 'bigquery', 'firebase', 'stripe').
const OVERRIDE_URL_SLUGS = new Set(Object.values(SUPABASE_LISTING_OVERRIDES).map((o) => o.slug))
// Reverse map: URL slug → listing DB slug (e.g. 'bigquery' → 'bigquery-wrapper').
const URL_SLUG_TO_LISTING: Record<string, string> = Object.fromEntries(
Object.entries(SUPABASE_LISTING_OVERRIDES).map(([listingSlug, { slug }]) => [slug, listingSlug])
)
// Only FDW wrappers need the Dashboard Integration treatment (install button + dashboard URL).
const MARKETPLACE_SLUGS = ['bigquery-wrapper', 'firebase-wrapper', 'stripe-wrapper']
/** Returns true if a listing is in the Supabase Marketplace. */
export function isMarketplaceListing(listing: CatalogListing): boolean {
if (MARKETPLACE_SLUGS.includes(listing.slug)) return true
return false
}
async function getMarketplaceListings(): Promise<Partner[]> {
const { data } = await marketplaceClient
.from('listings')
.select('*')
.not('published_in_catalog_at', 'is', null)
// Partners whose catalog pages are not yet ready to launch.
// Excluded from listing, detail, slug generation, and search.
const PRE_LAUNCH_CATALOG_BLOCKLIST = new Set<string>([])
return data?.map(toPartner) ?? []
/** Returns true if a listing is a Foreign Data Wrapper (FDW). */
function isFdwListing(listing: CatalogListing): boolean {
return !!listing.categories?.some(
(c) =>
c.name.toLowerCase().includes('foreign data wrapper') || c.slug.toLowerCase().includes('fdw')
)
}
/** Deduplicates and merges categories from across all of a partner's listings. */
function aggregateCategories(listings: CatalogListing[]): Category[] {
const seen = new Map<string, Category>()
for (const listing of listings) {
for (const cat of listing.categories ?? []) {
if (!seen.has(cat.slug)) seen.set(cat.slug, { name: cat.name, slug: cat.slug })
}
}
return Array.from(seen.values())
}
/**
* Lists all partner marketplace entries for the public website, from the configured database.
* Picks the listing to use for narrative content (description, docs, images…).
* Prefers a Partner Catalog-published listing, then a Dashboard Integration-published one, then any.
*/
function selectPrimaryListing(listings: CatalogListing[]): CatalogListing | undefined {
return (
// The catalog_listings view only returns catalog-published listings now (explicitly so, but that was
// implicitly the case before) so this doesn't make a ton of sense.
//listings.find((l) => !!l.published_in_catalog_at) ??
listings.find((l) => l.published_in_marketplace) ?? listings[0]
)
}
type MarketplacePartnerRow = {
slug: string
name: string | null
description: string | null
logo: string | null
website: string | null
type: 'technology' | 'expert' | null
}
function buildPartner(row: MarketplacePartnerRow, listings: CatalogListing[]): Partner {
const primary = selectPrimaryListing(listings)
return {
slug: row.slug,
title: row.name ?? primary?.partner_name ?? '',
builtBy: row.name ?? primary?.built_by ?? '',
description: row.description ?? primary?.description ?? '',
content: primary?.content ?? '',
websiteUrl: row.website ?? primary?.website_url ?? '',
docsUrl: primary?.documentation_url || null,
installUrl: primary?.marketplace_url ?? null,
// Prefer the company logo, fall back to the listing's partner logo, then listing logo
logo: fullImageUrl(row.logo ?? primary?.partner_logo ?? primary?.listing_logo ?? ''),
images: primary?.images?.map(fullImageUrl) ?? [],
youtubeId: primary?.youtube_id ?? null,
type: row.type ?? 'technology',
categories: aggregateCategories(listings),
featured: listings.some((l) => l.featured),
// the new view filters these
publishedInCatalog: true,
// FDW listings count as "Available in Marketplace" even without publish_dashboard.
publishedInMarketplace: listings.some(
(l) => l.published_in_marketplace || isMarketplaceListing(l)
),
}
}
/**
* Marketplace DB: list all partners and related listings
*/
async function getPartnersFromMarketplace(): Promise<Partner[]> {
const [{ data: partnersData }, { data: listingsData }] = await Promise.all([
marketplaceClient.from('partners').select('*'),
marketplaceClient.from('catalog_listings').select('*'),
])
if (!partnersData?.length) return []
// Check listing.slug first so the override works regardless of what partner_slug the DB has.
const overriddenListings = new Map<string, CatalogListing>()
const byPartnerSlug = new Map<string, CatalogListing[]>()
for (const listing of listingsData ?? []) {
if (listing.slug in SUPABASE_LISTING_OVERRIDES) {
overriddenListings.set(listing.slug, listing)
continue
}
const slug = listing.partner_slug
if (!slug || slug === SUPABASE_PARTNER_SLUG) continue
const arr = byPartnerSlug.get(slug) ?? []
arr.push(listing)
byPartnerSlug.set(slug, arr)
}
// Exclude 'supabase', overridden slugs, pre-launch partners, and partners with no
// catalog-published listing.
const regularPartners = partnersData
.filter(
(p) =>
p.slug &&
p.slug !== SUPABASE_PARTNER_SLUG &&
!OVERRIDE_URL_SLUGS.has(p.slug) &&
!PRE_LAUNCH_CATALOG_BLOCKLIST.has(p.slug) &&
byPartnerSlug.has(p.slug)
)
.flatMap((p) => {
const slug = p.slug
if (!slug) return []
return [
buildPartner(
{
slug,
name: p.name,
description: p.description,
logo: p.logo,
website: p.website,
type: p.type,
},
byPartnerSlug.get(slug) ?? []
),
]
})
const virtualPartners = Array.from(overriddenListings.entries()).map(([listingSlug, listing]) => {
const override = SUPABASE_LISTING_OVERRIDES[listingSlug]
return buildPartner(
{
slug: override.slug,
name: override.name,
description: listing.description,
logo: listing.listing_logo,
website: listing.website_url,
type: 'technology',
},
[listing]
)
})
return [...regularPartners, ...virtualPartners]
}
/**
* Derives a human-readable tab label from a listing's metadata — always the listing's own
* title (e.g. "BigQuery Wrapper", "Stripe Sync Engine"), so partners with more than one listing
* get distinct, identifiable tabs instead of generic "Guide"/"Integration" labels.
*/
function getLabelForListing(listing: CatalogListing): string {
return listing.title || listing.slug || 'Listing'
}
/**
* Marketplace DB: get a single partner by partner slug, including all their listings as tabs.
*/
async function getPartnerFromMarketplace(slug: string): Promise<Partner | null> {
// 'supabase' never appears directly as a catalog partner
if (slug === SUPABASE_PARTNER_SLUG) return null
// Partners blocked from the catalog until they're ready to launch
if (PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)) return null
// Supabase-owned listings remapped to independent partners.
// Accept both the clean URL slug ('bigquery') and the listing DB slug ('bigquery-wrapper').
const listingSlugForOverride =
URL_SLUG_TO_LISTING[slug] ?? (slug in SUPABASE_LISTING_OVERRIDES ? slug : null)
if (listingSlugForOverride) {
const override = SUPABASE_LISTING_OVERRIDES[listingSlugForOverride]
const { data: listing } = await marketplaceClient
.from('catalog_listings')
.select('*')
.eq('slug', listingSlugForOverride)
.maybeSingle()
if (!listing) return null
return {
...buildPartner(
{
slug: override.slug,
name: override.name,
description: listing.description,
logo: listing.listing_logo,
website: listing.website_url,
type: 'technology',
},
[listing]
),
listings: [
{
slug: listing.slug,
label: getLabelForListing(listing),
content: listing.content,
publishedInMarketplace: listing.published_in_marketplace || isMarketplaceListing(listing),
installUrl: listing.marketplace_url ?? null,
dashboardUrl:
listing.published_in_marketplace || isMarketplaceListing(listing)
? `https://supabase.com/dashboard/project/_/integrations/${isFdwListing(listing) ? listing.slug.replaceAll('-', '_') : listing.slug}/overview`
: null,
docsUrl: listing.documentation_url || null,
images: listing.images?.map(fullImageUrl) ?? [],
youtubeId: listing.youtube_id ?? null,
},
],
}
}
const [{ data: partnerData }, { data: listingsData }] = await Promise.all([
marketplaceClient.from('partners').select('*').eq('slug', slug).maybeSingle(),
marketplaceClient.from('catalog_listings').select('*').eq('partner_slug', slug),
])
const partnerSlug = partnerData?.slug
if (!partnerSlug) return null
const listings = listingsData ?? []
// No catalog-published listing → this partner isn't part of the catalog.
if (!listings.length) return null
const listingDetails: ListingDetail[] = listings.map((listing) => ({
slug: listing.slug,
label: getLabelForListing(listing),
content: listing.content,
publishedInMarketplace: listing.published_in_marketplace || isMarketplaceListing(listing),
installUrl: listing.marketplace_url ?? null,
dashboardUrl:
listing.published_in_marketplace || isMarketplaceListing(listing)
? `https://supabase.com/dashboard/project/_/integrations/${isFdwListing(listing) ? listing.slug.replaceAll('-', '_') : listing.slug}/overview`
: null,
docsUrl: listing.documentation_url || null,
images: listing.images?.map(fullImageUrl) ?? [],
youtubeId: listing.youtube_id ?? null,
}))
return {
...buildPartner(
{
slug: partnerSlug,
name: partnerData.name,
description: partnerData.description,
logo: partnerData.logo,
website: partnerData.website,
type: partnerData.type,
},
listings
),
listings: listingDetails,
}
}
/**
* Marketplace DB: list all partner slugs (for static path generation).
* Excludes 'supabase'; adds the remapped listing slugs instead.
*/
async function getPartnerSlugsFromMarketplace(): Promise<string[]> {
const [{ data: catalogListingRows }, { data: overriddenRows }] = await Promise.all([
// Derive partner slugs from catalog-published listings, not the raw partners table —
// a partner with no catalog-published listing isn't part of the Partner Catalog.
marketplaceClient.from('catalog_listings').select('partner_slug'),
// Fetch overridden listing slugs directly by listing slug (not by partner_slug).
marketplaceClient
.from('catalog_listings')
.select('slug')
.in('slug', Object.keys(SUPABASE_LISTING_OVERRIDES)),
])
// Exclude 'supabase', any slug covered by an override, and pre-launch partners.
const catalogPartnerSlugs = new Set(
catalogListingRows?.flatMap((row) => (row.partner_slug ? [row.partner_slug] : [])) ?? []
)
const partnerSlugs = Array.from(catalogPartnerSlugs).filter(
(slug) =>
slug !== SUPABASE_PARTNER_SLUG &&
!OVERRIDE_URL_SLUGS.has(slug) &&
!PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)
)
// Map each listing DB slug to its clean URL slug (e.g. 'bigquery-wrapper' → 'bigquery').
const overriddenSlugs =
overriddenRows?.flatMap((row) => {
const override = row.slug ? SUPABASE_LISTING_OVERRIDES[row.slug] : null
return override ? [override.slug] : []
}) ?? []
return [...partnerSlugs, ...overriddenSlugs]
}
/**
* Marketplace DB: search (returns one result per partner, not per listing)
*/
async function searchPartnersFromMarketplace(search: string): Promise<Partner[] | null> {
const searchTerm = search.trim()
if (!searchTerm) return getPartnersFromMarketplace()
const searchPattern = `%${searchTerm}%`
const { data: nameMatchedPartners, error: partnersError } = await marketplaceClient
.from('partners')
.select('slug')
.ilike('name', searchPattern)
if (partnersError) {
console.error('Marketplace search error:', partnersError)
return null
}
const nameMatchedSlugs = nameMatchedPartners?.flatMap((p) => (p.slug ? [p.slug] : [])) ?? []
const orConditions = [
`title.ilike.${searchPattern}`,
`description.ilike.${searchPattern}`,
`partner_name.ilike.${searchPattern}`,
]
if (nameMatchedSlugs.length) {
orConditions.push(`partner_slug.in.(${nameMatchedSlugs.join(',')})`)
}
const { data, error } = await marketplaceClient
.from('catalog_listings')
.select('*')
.or(orConditions.join(','))
if (error) {
console.error('Marketplace search error:', error)
return null
}
// Use clean URL slug as map key for overridden listings.
const byPartnerSlug = new Map<string, CatalogListing[]>()
for (const listing of data ?? []) {
if (listing.slug in SUPABASE_LISTING_OVERRIDES) {
byPartnerSlug.set(SUPABASE_LISTING_OVERRIDES[listing.slug].slug, [listing])
continue
}
const slug = listing.partner_slug
if (!slug || slug === SUPABASE_PARTNER_SLUG || PRE_LAUNCH_CATALOG_BLOCKLIST.has(slug)) continue
const arr = byPartnerSlug.get(slug) ?? []
arr.push(listing)
byPartnerSlug.set(slug, arr)
}
// Fetch partner-level names (which take priority over listing.partner_name, see buildPartner)
// for every non-overridden partner surfaced above.
const plainPartnerSlugs = Array.from(byPartnerSlug.keys()).filter(
(slug) => !URL_SLUG_TO_LISTING[slug]
)
const { data: partnerRows } = plainPartnerSlugs.length
? await marketplaceClient.from('partners').select('slug, name').in('slug', plainPartnerSlugs)
: { data: [] }
const partnerNameBySlug = new Map((partnerRows ?? []).map((p) => [p.slug, p.name]))
return Array.from(byPartnerSlug.entries()).map(([partnerSlug, listings]) => {
const first = listings[0]
const listingSlug = URL_SLUG_TO_LISTING[partnerSlug]
const isOverridden = !!listingSlug
return buildPartner(
{
slug: partnerSlug,
name: isOverridden
? SUPABASE_LISTING_OVERRIDES[listingSlug].name
: (partnerNameBySlug.get(partnerSlug) ?? first.partner_name),
description: first.description,
logo: isOverridden ? first.listing_logo : (first.partner_logo ?? first.listing_logo),
website: first.website_url,
type: 'technology',
},
listings
)
})
}
// ---------------------------------------------------------------------------
// Catalog API — always uses the marketplace DB directly, independent of isUseMarketplaceDb.
// The Partner Catalog is backed by the marketplace DB; the legacy misc DB does not carry
// the listings (BigQuery/Firebase/Stripe FDW integrations, etc.) that the catalog needs.
// ---------------------------------------------------------------------------
export const listCatalogPartners = getPartnersFromMarketplace
export const searchCatalogPartners = searchPartnersFromMarketplace
export const getCatalogPartner = getPartnerFromMarketplace
export const listCatalogPartnerSlugs = getPartnerSlugsFromMarketplace
// ---------------------------------------------------------------------------
// Public API — respects isUseMarketplaceDb for non-catalog pages (e.g. /partners/integrations).
// ---------------------------------------------------------------------------
/**
* Lists all partner entries for the public website, from the configured database.
* Returns one entry per partner company, with categories and Dashboard Integration flags
* aggregated across all of that partner's listings.
*/
export async function listPartners(): Promise<Partner[]> {
if (isUseMarketplaceDb) {
return getMarketplaceListings()
return getPartnersFromMarketplace()
} else {
const { data } = await supabase
.from('partners')
@@ -74,21 +451,13 @@ export async function listPartners(): Promise<Partner[]> {
}
}
async function getMarketplaceListingSlugs(): Promise<string[]> {
const { data } = await marketplaceClient
.from('listings')
.select('slug')
.not('published_in_catalog_at', 'is', null)
return data?.map((row) => row.slug) ?? []
}
/**
* Lists all available partner marketplace listing slugs, from the configured database.
* Lists all available partner slugs, from the configured database.
* Used for static path generation on the partner detail pages.
*/
export async function listPartnerSlugs(): Promise<string[]> {
if (isUseMarketplaceDb) {
return getMarketplaceListingSlugs()
return getPartnerSlugsFromMarketplace()
} else {
const { data } = await supabase
.from('partners')
@@ -100,36 +469,13 @@ export async function listPartnerSlugs(): Promise<string[]> {
}
}
async function searchMarketplaceListings(search: string): Promise<Partner[] | null> {
const searchTerm = search.trim()
let query = marketplaceClient
.from('listings')
.select('*')
.not('published_in_catalog_at', 'is', null)
if (searchTerm) {
const searchPattern = `%${searchTerm}%`
query = query.or(
`title.ilike.${searchPattern},description.ilike.${searchPattern},partner_name.ilike.${searchPattern}`
)
}
const { data, error } = await query
if (error) {
console.error('Marketplace search error:', error)
return null
}
return data?.map(toPartner) ?? []
}
/**
* Searches for partner marketplace listings, from the configured database.
* Searches for partners, from the configured database.
* Returns one result per partner company (not per listing).
*/
export async function searchPartners(search: string): Promise<Partner[] | null> {
if (isUseMarketplaceDb) {
return searchMarketplaceListings(search)
return searchPartnersFromMarketplace(search)
} else {
const searchTerm = search.trim()
let query = supabase
@@ -155,25 +501,15 @@ export async function searchPartners(search: string): Promise<Partner[] | null>
}
}
async function getMarketplaceListing(slug: string): Promise<Partner | null> {
const { data } = await marketplaceClient
.from('listings')
.select('*')
.eq('slug', slug)
.not('published_in_catalog_at', 'is', null)
.single()
return data ? toPartner(data) : null
}
/**
* Get a single partner by slug, from the configured database.
* For the marketplace DB, slug is the partner slug (not a listing slug).
*/
export async function getPartner(slug: string): Promise<Partner | null> {
if (isUseMarketplaceDb) {
return getMarketplaceListing(slug)
return getPartnerFromMarketplace(slug)
} else {
let { data } = await supabase
const { data } = await supabase
.from('partners')
.select('*')
.eq('type', 'technology')
+76 -42
View File
@@ -2328,85 +2328,112 @@ module.exports = [
source: '/blog/flutter-authentication-and-authorization-with-rls',
destination: '/blog/flutter-authorization-with-rls',
},
// /partners/integrations → /partners/catalog redirects
// Wrapper listing slugs → clean partner slugs (open the matching listing tab)
{
permanent: true,
source: '/partners/catalog/bigquery-wrapper',
destination: '/partners/catalog/bigquery?tab=bigquery-wrapper',
},
{
permanent: true,
source: '/partners/catalog/firebase-wrapper',
destination: '/partners/catalog/firebase?tab=firebase-wrapper',
},
{
permanent: true,
source: '/partners/catalog/stripe-wrapper',
destination: '/partners/catalog/stripe?tab=stripe-wrapper',
},
{
permanent: true,
source: '/partners/catalog/aikido-security',
destination: '/partners/catalog/aikido',
},
{
permanent: true,
source: '/partners/integrations',
destination: '/partners/catalog',
},
{
permanent: true,
source: '/docs/guides/integrations/appsmith',
destination: '/partners/integrations/appsmith',
destination: '/partners/catalog/appsmith',
},
{
permanent: true,
source: '/docs/guides/integrations/auth0',
destination: '/partners/integrations/auth0',
destination: '/partners/catalog/auth0',
},
{
permanent: true,
source: '/docs/guides/integrations/authsignal',
destination: '/partners/integrations/authsignal',
destination: '/partners/catalog/authsignal',
},
{
permanent: true,
source: '/docs/guides/integrations/bracket',
destination: '/partners/integrations/bracket',
destination: '/partners/catalog/bracket',
},
{
permanent: true,
source: '/docs/guides/integrations/clerk',
destination: '/partners/integrations/clerk',
destination: '/partners/catalog/clerk',
},
{
permanent: true,
source: '/docs/guides/integrations/cloudflare-workers',
destination: '/partners/integrations/cloudflare-workers',
destination: '/partners/catalog/cloudflare-workers',
},
{
permanent: true,
source: '/docs/guides/integrations/dhiwise',
destination: '/partners/integrations/dhiwise',
destination: '/partners/catalog/dhiwise',
},
{
permanent: true,
source: '/docs/guides/integrations/directus',
destination: '/partners/integrations/directus',
destination: '/partners/catalog/directus',
},
{
permanent: true,
source: '/docs/guides/integrations/draftbit',
destination: '/partners/integrations/draftbit',
destination: '/partners/catalog/draftbit',
},
{
permanent: true,
source: '/docs/guides/integrations/estuary',
destination: '/partners/integrations/estuary',
destination: '/partners/catalog/estuary',
},
{
permanent: true,
source: '/docs/guides/integrations/fezto',
destination: '/partners/integrations/fezto',
destination: '/partners/catalog/fezto',
},
{
permanent: true,
source: '/docs/guides/integrations/flutterflow',
destination: '/partners/integrations/flutterflow',
destination: '/partners/catalog/flutterflow',
},
{
permanent: true,
source: '/docs/guides/integrations/forestadmin',
destination: '/partners/integrations/forestadmin',
destination: '/partners/catalog/forestadmin',
},
{
permanent: true,
source: '/docs/guides/integrations/illa',
destination: '/partners/integrations/illa',
destination: '/partners/catalog/illa',
},
{
permanent: true,
source: '/docs/guides/integrations/integrations',
destination: '/partners/integrations',
destination: '/partners/catalog',
},
{
permanent: true,
source: '/docs/guides/integrations/keyri',
destination: '/partners/integrations/keyri',
destination: '/partners/catalog/keyri',
},
{
permanent: true,
@@ -2421,108 +2448,110 @@ module.exports = [
{
permanent: true,
source: '/docs/guides/integrations/onesignal',
destination: '/partners/integrations/onesignal',
destination: '/partners/catalog/onesignal',
},
{
permanent: true,
source: '/docs/guides/integrations/passage',
destination: '/partners/integrations/passage',
destination: '/partners/catalog/passage',
},
{
permanent: true,
source: '/docs/guides/integrations/pgmustard',
destination: '/partners/integrations/pgmustard',
destination: '/partners/catalog/pgmustard',
},
{
permanent: true,
source: '/docs/guides/integrations/picket',
destination: '/partners/integrations/picket',
destination: '/partners/catalog/picket',
},
{
permanent: true,
source: '/docs/guides/integrations/plasmic',
destination: '/partners/integrations/plasmic',
destination: '/partners/catalog/plasmic',
},
{
permanent: true,
source: '/docs/guides/integrations/polyscale',
destination: '/partners/integrations/polyscale',
destination: '/partners/catalog/polyscale',
},
{
permanent: true,
source: '/docs/guides/integrations/prisma',
destination: '/partners/integrations/prisma',
destination: '/partners/catalog/prisma',
},
{
permanent: true,
source: '/partners/integrations/atomic_crm',
destination: '/partners/integrations/atomic-crm',
destination: '/partners/catalog/atomic-crm',
},
{
permanent: true,
source: '/partners/integrations/refine_dev',
destination: '/partners/integrations/refine',
},
{
permanent: true,
source: '/partners/integrations/sequin_io',
destination: '/partners/integrations/sequin',
destination: '/partners/catalog/refine',
},
{
permanent: true,
source: '/partners/integrations/supabase_wrapper_bigquery',
destination: '/partners/integrations/bigquery-wrapper',
destination: '/partners/catalog/bigquery?tab=bigquery-wrapper',
},
{
permanent: true,
source: '/partners/integrations/supabase_wrapper_firebase',
destination: '/partners/integrations/firebase-wrapper',
destination: '/partners/catalog/firebase?tab=firebase-wrapper',
},
{
permanent: true,
source: '/partners/integrations/supabase_wrapper_stripe',
destination: '/partners/integrations/stripe-wrapper',
destination: '/partners/catalog/stripe?tab=stripe-wrapper',
},
{
permanent: true,
source: '/docs/guides/integrations/sequin',
destination: '/partners/integrations/sequin',
destination: '/partners/catalog/sequin',
},
{
permanent: true,
source: '/docs/guides/integrations/snaplet',
destination: '/partners/integrations/snaplet',
destination: '/partners/catalog/snaplet',
},
{
permanent: true,
source: '/docs/guides/integrations/stytch',
destination: '/partners/integrations/stytch',
destination: '/partners/catalog/stytch',
},
{
permanent: true,
source: '/docs/guides/integrations/supertokens',
destination: '/partners/integrations/supertokens',
destination: '/partners/catalog/supertokens',
},
{
permanent: true,
source: '/docs/guides/integrations/vercel',
destination: '/partners/integrations/vercel',
destination: '/partners/catalog/vercel',
},
{
permanent: true,
source: '/docs/guides/integrations/weweb',
destination: '/partners/integrations/weweb',
destination: '/partners/catalog/weweb',
},
{
permanent: true,
source: '/docs/guides/integrations/zuplo',
destination: '/partners/integrations/zuplo',
destination: '/partners/catalog/zuplo',
},
// toddle.dev pivoted to nordcraft
{
permanent: true,
source: '/partners/integrations/toddle',
destination: '/partners/integrations/nordcraft',
destination: '/partners/catalog/nordcraft',
},
// Catch-all: any other /partners/integrations/<slug> not covered by a specific rule above.
// Must come after all specific /partners/integrations/* rules
{
permanent: true,
source: '/partners/integrations/:slug*',
destination: '/partners/catalog/:slug*',
},
{
permanent: true,
@@ -2948,7 +2977,12 @@ module.exports = [
{
permanent: true,
source: '/docs/guides/platform/marketplace',
destination: '/docs/guides/integrations/supabase-marketplace',
destination: '/docs/guides/integrations/partner-catalog',
},
{
permanent: true,
source: '/docs/guides/integrations/supabase-marketplace',
destination: '/docs/guides/integrations/partner-catalog',
},
{
permanent: true,
-39
View File
@@ -1,39 +0,0 @@
import { GetStaticPaths, GetStaticProps } from 'next'
import supabase from '~/lib/supabaseMisc'
import Error404 from '../404'
function PartnerPage() {
// Existing short partner URLs redirect to their current integration pages.
return <Error404 />
}
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: 'blocking',
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
let { data: partner } = await supabase
.from('partners')
.select('*')
.eq('approved', true)
.eq('slug', params!.slug as string)
.single()
if (!partner || partner.type === 'expert') {
return {
notFound: true,
}
}
return {
redirect: {
permanent: false,
destination: `/partners/integrations/${partner.slug}`,
},
}
}
export default PartnerPage
+2 -2
View File
@@ -36,7 +36,7 @@ const Partners = () => {
/>
<DefaultLayout>
<div className="relative bg-alternative overflow-hidden">
<SectionContainer className="overflow-hidden pt-8 pb-12 md:pt-12">
<SectionContainer className="overflow-hidden pt-8 pb-12">
<ProductHeaderCentered {...pageData.heroSection} />
</SectionContainer>
<div className="relative z-20 w-full flex py-16 mb-16 -mt-10 md:mb-24 md:-mt-20 justify-center gap-2 overflow-hidden mx-auto max-w-4xl before:content[''] before:absolute before:inset-0 before:w-full before:bg-[linear-gradient(to_right,var(--background-alternative-default)_0%,transparent_10%,transparent_90%,var(--background-alternative-default)_100%)] before:z-10">
@@ -139,7 +139,7 @@ const Partners = () => {
Use your favorite tools with Supabase.
</p>
</div>
<Link href="/partners/integrations" className="absolute inset-0" />
<Link href="/partners/catalog" className="absolute inset-0" />
</Panel>
</div>
</div>
@@ -1,356 +0,0 @@
import { remarkCodeHike, type CodeHikeConfig } from '@code-hike/mdx'
import { CH } from '@code-hike/mdx/components'
import { ChevronLeft, ExternalLink } from 'lucide-react'
import { type GetStaticPaths, type GetStaticProps } from 'next'
import type { SerializeResult as MDXRemoteSerializeResult } from 'next-mdx-remote-client'
import { MDXClient } from 'next-mdx-remote-client/csr'
import { serialize } from 'next-mdx-remote-client/serialize'
import { NextSeo } from 'next-seo'
import Image from 'next/image'
import Link from 'next/link'
import { useState, type Dispatch, type SetStateAction } from 'react'
import remarkGfm from 'remark-gfm'
import 'swiper/css'
import ImageModal from '~/components/ImageModal'
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 { 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 { 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<SetStateAction<string | null>>) {
const components = {
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<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
) => {
return <img {...props} onClick={() => callback(props.src!.toString())} />
},
}
return components
}
type PartnerData = {
partner: Partner
overview: MDXRemoteSerializeResult<Record<string, unknown>, Record<string, unknown>>
}
function Partner({ partner, overview }: PartnerData) {
const [focusedImage, setFocusedImage] = useState<string | null>(null)
const isNarrow = useBreakpoint('lg')
if (!partner) return <Error404 />
return (
<>
<NextSeo
title={`${partner.title} | Works With Supabase`}
description={partner.description}
openGraph={{
title: `${partner.title} | Works With Supabase`,
description: partner.description,
url: `https://supabase.com/partners/integrations/${partner.slug}`,
images: [
{
url: partner.images ? partner.images[0] : partner.logo,
},
],
}}
/>
{focusedImage ? (
<ImageModal
visible
onCancel={() => setFocusedImage(null)}
size="xxlarge"
className="w-full outline-hidden"
>
<Image
layout="responsive"
objectFit="contain"
width={1152}
height={766}
src={focusedImage!}
alt={partner.title}
/>
</ImageModal>
) : null}
<DefaultLayout>
<SectionContainer>
<div className="col-span-12 mx-auto mb-2 space-y-10 lg:col-span-2">
{/* Back button */}
<Link
href="/partners/integrations"
className="text-foreground hover:text-foreground-lighter flex cursor-pointer items-center transition-colors"
>
<ChevronLeft width={14} height={14} />
Back
</Link>
<div className="flex items-center space-x-4">
<Image
layout="fixed"
width={56}
height={56}
className="bg-surface-200 shrink-f0 h-14 w-14 rounded-full"
src={partner.logo}
alt={partner.title}
/>
<h1 className="h1" style={{ marginBottom: 0 }}>
{partner.title}
</h1>
</div>
<div
className="bg-linear-to-t from-background-alternative to-background border-b p-6 [&_.swiper-container]:overflow-visible"
style={{ marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' }}
>
<SectionContainer className="py-0! px-3! lg:px-12! xl:p-0! mx-auto max-w-5xl">
<Swiper
initialSlide={0}
spaceBetween={20}
slidesPerView={4}
speed={300}
grabCursor
centeredSlides={false}
centerInsufficientSlides={false}
breakpoints={{
320: {
slidesPerView: 1.25,
centeredSlides: false,
spaceBetween: 10,
},
720: {
slidesPerView: 2,
centeredSlides: false,
spaceBetween: 10,
},
920: {
slidesPerView: 3,
centeredSlides: false,
},
1024: {
slidesPerView: 4,
},
1280: {
slidesPerView: 5,
},
}}
>
{partner.images?.map((image: any, i: number) => {
return (
<SwiperSlide key={i}>
<div className="relative block overflow-hidden rounded-md">
<Image
layout="responsive"
objectFit="contain"
placeholder="blur"
blurDataURL="/images/blur.png"
width={1460}
height={960}
src={image}
alt={partner.title}
onClick={() => setFocusedImage(image)}
/>
</div>
</SwiperSlide>
)
})}
</Swiper>
</SectionContainer>
</div>
<div className="grid gap-y-12 lg:grid-cols-8 lg:space-x-12">
{isNarrow && <PartnerDetails partner={partner} />}
<div className="lg:col-span-5 overflow-hidden">
<h2
className="text-foreground"
style={{ fontSize: '1.5rem', marginBottom: '1rem' }}
>
Overview
</h2>
<div className="prose">
{'error' in overview ? (
<p>Error rendering integration page: {overview.error.message}</p>
) : (
// @ts-expect-error: Gildas - This is because CodeHide put its components under the CH namespace. It works but the MDXClient types are stricter
<MDXClient {...overview} components={mdxComponents(setFocusedImage)} />
)}
</div>
</div>
{!isNarrow && <PartnerDetails partner={partner} />}
</div>
{partner.installUrl && (
<div className="bg-background hover:border-default-control border-default rounded-2xl border p-10 drop-shadow-xs max-w-5xl mx-auto mt-12">
<div className="flex flex-row justify-between">
<h1 className="text-2xl font-medium self-center">
Get started with {partner.title} and Supabase.
</h1>
<a href={partner.installUrl} target="_blank" rel="noreferrer">
<Button size="medium" variant="secondary">
Add integration
</Button>
</a>
</div>
</div>
)}
</div>
</SectionContainer>
</DefaultLayout>
</>
)
}
const PartnerDetails = ({ partner }: { partner: Partner }) => {
const videoThumbnail = partner.youtubeId
? `https://img.youtube.com/vi/${partner.youtubeId}/0.jpg`
: undefined
return (
<div className="lg:col-span-3">
<div className="sticky top-20 flex flex-col gap-4">
<h2 className="text-foreground" style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>
Details
</h2>
{partner.youtubeId && (
<ExpandableVideo
videoId={partner.youtubeId}
// imgUrl={videoThumbnail}
imgOverlayText="Watch an introductory video"
triggerContainerClassName="w-full"
/>
)}
<div className="text-foreground divide-y">
{partner.type === 'technology' && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Developer</span>
<span className="text-foreground">{partner.builtBy}</span>
</div>
)}
{partner.categories.map((category) => (
<div key={category.slug} className="flex items-center justify-between py-2">
<span className="text-lighter">Category</span>
<Link
href={`/partners/integrations#${category.slug}`}
className="text-brand-link hover:underline transition-colors"
>
{category.name}
</Link>
</div>
))}
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Website</span>
<a
href={partner.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
{new URL(partner.websiteUrl).host}
</a>
</div>
{partner.type === 'technology' && partner.docsUrl && (
<div className="flex items-center justify-between py-2">
<span className="text-foreground-lighter">Documentation</span>
<a
href={partner.docsUrl}
target="_blank"
rel="noreferrer"
className="text-brand-link hover:underline transition-colors"
>
<span className="flex items-center space-x-1">
<span>Learn</span>
<ExternalLink width={14} height={14} />
</span>
</a>
</div>
)}
</div>
<p className="text-foreground-light text-sm">
Third-party integrations and docs are managed by Supabase partners.
</p>
</div>
</div>
)
}
// This function gets called at build time
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',
}
}
// This also gets called at build time
export const getStaticProps: GetStaticProps<PartnerData> = async ({ params }) => {
const partner = await getPartner(params!.slug as string)
if (!partner) {
return {
notFound: true,
}
}
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
showCopyButton: true,
skipLanguages: [],
autoImport: false,
}
// Parse markdown
const overview = await serialize({
source: partner.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]],
},
},
})
return {
props: { partner, overview },
revalidate: 1800, // 30 minutes
}
}
export default Partner
@@ -1,193 +0,0 @@
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import BecomeAPartner from '~/components/Partners/BecomeAPartner'
import PartnerLinkBox from '~/components/Partners/PartnerLinkBox'
import { listPartners, searchPartners } from '~/lib/marketplaceDb'
import { type Category, type Partner } from '~/types/partners'
import { Loader, Search } from 'lucide-react'
import { NextSeo } from 'next-seo'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
import { useDebounce } from 'use-debounce'
import TileGrid from '../../../components/Partners/TileGrid'
export async function getStaticProps(): Promise<{ props: Props; revalidate: number }> {
return {
props: {
partners: await listPartners(),
},
// TODO: consider using Next.js' On-demand Revalidation with Supabase Database Webhooks instead
revalidate: 1800, // 30 minutes
}
}
interface Props {
partners: Partner[]
}
function IntegrationPartnersPage(props: Props) {
const initialPartners = props.partners
const [partners, setPartners] = useState(initialPartners)
const categoryMap: { [slug: string]: Category } = {}
initialPartners.forEach((p) =>
p.categories.forEach((c) => {
if (!(c.slug in categoryMap)) {
categoryMap[c.slug] = c
}
})
)
const allCategories = Object.keys(categoryMap)
.toSorted()
.map((slug) => categoryMap[slug])
const router = useRouter()
const meta_title = 'Find an Integration'
const meta_description = `Use your favorite tools with Supabase.`
const [search, setSearch] = useState('')
const [debouncedSearchTerm] = useDebounce(search, 300)
const [isSearching, setIsSearching] = useState(false)
const searchIdRef = useRef(0)
useEffect(() => {
if (debouncedSearchTerm.trim() === '') {
setIsSearching(false)
setPartners(initialPartners)
return
}
setIsSearching(true)
const currentSearchId = ++searchIdRef.current
searchPartners(debouncedSearchTerm)
.then((results) => {
if (currentSearchId === searchIdRef.current) {
setPartners(results ?? [])
}
})
.catch(() => {
if (currentSearchId === searchIdRef.current) {
setPartners([])
}
})
.finally(() => {
if (currentSearchId === searchIdRef.current) {
setIsSearching(false)
}
})
}, [debouncedSearchTerm, initialPartners])
return (
<>
<NextSeo
title={meta_title}
description={meta_description}
openGraph={{
title: meta_title,
description: meta_description,
url: `https://supabase.com/partners/integrations`,
images: [
{
url: `https://supabase.com${router.basePath}/images/og/integrations.png`, // TODO
},
],
}}
/>
<DefaultLayout className="bg-alternative">
<SectionContainer className="space-y-16">
<div>
<h1 className="h1">{meta_title}</h1>
<p className="text-foreground-lighter text-xl">{meta_description}</p>
</div>
{/* Title */}
<div className="grid space-y-12 md:gap-8 lg:grid-cols-12 lg:gap-16 lg:space-y-0 xl:gap-16">
<div className="lg:col-span-4 xl:col-span-3">
{/* Horizontal link menu */}
<div className="space-y-6">
{/* Search Bar */}
<InputGroup className="w-full">
<InputGroupInput
size="small"
autoComplete="off"
type="search"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
{isSearching && (
<InputGroupAddon align="inline-end">
<span className="mr-1 animate-spin text-white">
<Loader />
</span>
</InputGroupAddon>
)}
</InputGroup>
<div className="hidden lg:block">
<div className="text-foreground-lighter mb-2 text-sm">Categories</div>
<div className="space-y-1">
{allCategories.map((category) => (
<button
key={category.slug}
onClick={() => router.push(`#${category.slug}`)}
className="text-foreground-light block text-base cursor-pointer"
>
{category.name}
</button>
))}
</div>
</div>
<div className="space-y-4">
<div className="text-foreground-lighter mb-2 text-sm">Explore more</div>
<div className="grid grid-cols-2 gap-8 lg:grid-cols-1">
<PartnerLinkBox
href={`/partners/integrations#become-a-partner`}
title="Become a partner"
color="brand"
description="Fill out a quick 30 second form to apply to become a partner"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
}
/>
</div>
</div>
</div>
</div>
<div className="lg:col-span-8 xl:col-span-9">
{/* Partner Tiles */}
<div className="grid space-y-10">
{partners?.length ? (
<TileGrid partners={partners} />
) : (
<p className="h2">No Partners Found</p>
)}
</div>
</div>
</div>
</SectionContainer>
<BecomeAPartner />
</DefaultLayout>
</>
)
}
export default IntegrationPartnersPage
@@ -4,16 +4,16 @@
{
"name": "supabase",
"type": "archive",
"description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).",
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase.tar.gz",
"digest": "sha256:5acb0974e54db43ac66fcb55170dee2059b423d5253a9c1f14a51df44358f07a"
"description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, declarative schemas, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).",
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase.tar.gz",
"digest": "sha256:35dac7224b4c9594d6db63f197120ea4168581196edd741164c925aaeab6ec83"
},
{
"name": "supabase-postgres-best-practices",
"type": "archive",
"description": "Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.",
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase-postgres-best-practices.tar.gz",
"digest": "sha256:2db40cf84865e73c908ff080d101ab1ca60829cd379f4e6c15d7287d33ce61e5"
"url": "https://github.com/supabase/agent-skills/releases/download/v0.1.6/supabase-postgres-best-practices.tar.gz",
"digest": "sha256:57a5315af73864add7bc42eaa3d5c9f309c767d50185583165e6ecd1abd30404"
}
]
}
+17
View File
@@ -7,9 +7,23 @@ export type Category = {
slug: string
}
export type ListingDetail = {
slug: string
label: string
content: string
publishedInMarketplace: boolean
installUrl: string | null
dashboardUrl?: string | null
docsUrl: string | null
images: string[]
youtubeId: string | null
}
export type Partner = {
categories: Category[]
featured: boolean
publishedInCatalog: boolean
publishedInMarketplace?: boolean
type: 'technology' | 'expert'
slug: string
title: string
@@ -22,6 +36,7 @@ export type Partner = {
logo: string
images: string[]
youtubeId: string | null
listings?: ListingDetail[]
}
export function toPartner(dbPartner: DbPartner): Partner {
@@ -44,6 +59,8 @@ export function toPartner(dbPartner: DbPartner): Partner {
return {
categories: [{ name: category, slug: category.toLowerCase() }],
featured: featured ?? false,
publishedInCatalog: false, // has at least one listing shown on the partner catalog page
publishedInMarketplace: false, // has at least one one-click-installable listing in the dashboard
type,
slug,
title,
+53
View File
@@ -0,0 +1,53 @@
import {
BarChart3,
Boxes,
Cable,
Cpu,
CreditCard,
Database,
Fingerprint,
KeyRound,
Mail,
MessageSquare,
MousePointerClick,
Package2,
Server,
ShieldCheck,
Webhook,
Wrench,
Zap,
type LucideIcon,
} from 'lucide-react'
/**
* Maps integration category slugs to Lucide icons.
* Used in both Supabase Studio's Dashboard Integrations and the public Partner Catalog.
* Unknown slugs fall back to the neutral Boxes icon.
*/
export const CATEGORY_ICONS: Record<string, LucideIcon> = {
observability: BarChart3,
security: ShieldCheck,
billing: CreditCard,
secrets: KeyRound,
email: Mail,
wrappers: Database,
ai: Cpu,
ai_vectors: Cpu,
storage: Package2,
postgres_extension: Database,
wrapper: Cable,
devtools: Wrench,
auth: Fingerprint,
'low-code': MousePointerClick,
'data-platform': Server,
api: Webhook,
'caching-offline-first': Zap,
messaging: MessageSquare,
'foreign-data-wrapper': Cable,
fdw: Cable,
}
export const getCategoryIcon = (slug: string | null | undefined): LucideIcon => {
if (!slug) return Boxes
return CATEGORY_ICONS[slug] ?? Boxes
}
+20 -2
View File
@@ -15,7 +15,7 @@ export type Database = MergeDeep<
{
public: {
Views: {
listings: {
catalog_listings: {
Row: {
// add a type for the JSON structure
categories: Category[]
@@ -31,6 +31,7 @@ export type Database = MergeDeep<
website_url: string
documentation_url: string
listing_logo: string
published_in_marketplace: boolean
}
}
marketplace_listings: {
@@ -56,8 +57,25 @@ export type Database = MergeDeep<
}
>
export type Listing = Database['public']['Views']['listings']['Row']
export type CatalogListing = Database['public']['Views']['catalog_listings']['Row']
export type MarketplaceListing = Database['public']['Views']['marketplace_listings']['Row']
export type Partner = Database['public']['Views']['partners']['Row']
// 'supabase' never gets its own catalog partner page — getPartnerFromMarketplace in
// apps/www/lib/marketplaceDb.ts always 404s on this slug.
export const SUPABASE_PARTNER_SLUG = 'supabase'
// Supabase-owned listings that are remapped to appear as independent partners in the Partner
// Catalog, under a clean, partner-like URL slug (e.g. listing 'bigquery-wrapper' renders at
// /partners/catalog/bigquery). Key = listing DB slug; value = { display name, clean URL slug
// for the catalog page }. Shared so apps/www and apps/docs never drift out of sync.
export const SUPABASE_LISTING_OVERRIDES: Record<string, { name: string; slug: string }> = {
'bigquery-wrapper': { name: 'BigQuery', slug: 'bigquery' },
'firebase-wrapper': { name: 'Firebase', slug: 'firebase' },
'stripe-wrapper': { name: 'Stripe', slug: 'stripe' },
vercel: { name: 'Vercel', slug: 'vercel' },
cyberduck: { name: 'Cyberduck', slug: 'cyberduck' },
}
export const createMarketplaceClient = () => {
const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || ''
+33
View File
@@ -6,6 +6,39 @@ export type Database = {
[_ in never]: never
}
Views: {
catalog_listings: {
Row: {
built_by: string | null
categories: Json | null
content: string | null
description: string | null
documentation_url: string | null
featured: boolean | null
id: string | null
images: string[] | null
listing_logo: string | null
listing_tsv: unknown
marketplace_url: string | null
partner_id: string | null
partner_logo: string | null
partner_name: string | null
partner_slug: string | null
published_in_marketplace: boolean | null
slug: string | null
title: string | null
website_url: string | null
youtube_id: string | null
}
Relationships: [
{
foreignKeyName: 'listings_partner_id_fkey'
columns: ['partner_id']
isOneToOne: false
referencedRelation: 'partners'
referencedColumns: ['id']
},
]
}
categories: {
Row: {
description: string | null
+2
View File
@@ -26,6 +26,7 @@
"valtio": "catalog:"
},
"devDependencies": {
"lucide-react": "*",
"@types/lodash": "4.17.5",
"@types/node": "catalog:",
"@types/react": "catalog:",
@@ -41,6 +42,7 @@
"peerDependencies": {
"@supabase/auth-js": "catalog:",
"@supabase/supabase-js": "catalog:",
"lucide-react": "*",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
@@ -246,7 +246,6 @@ export default function MarketingForm({
)
return
}
// Strip values for fields that are currently hidden so stale data doesn't leak.
const submittedValues = Object.fromEntries(
Object.entries(values).filter(([name]) => visibleFieldNames.has(name))
+11
View File
@@ -175,6 +175,17 @@ export const checkboxFieldSchema = formFieldBase.extend({
groupRequired: z.boolean().optional().default(false),
})
/**
* Multi-select rendered as a list of checkboxes. Values are stored as a
* semicolon-separated string of the selected option `value`s — the format
* HubSpot expects for multi-select properties. Notion's multi_select type
* splits on the same delimiter when received.
*/
export const checkboxGroupFieldSchema = formFieldBase.extend({
type: z.literal('checkbox-group'),
options: z.array(z.object({ label: z.string(), value: z.string() })).min(1),
})
export const formFieldSchema = z.discriminatedUnion('type', [
textFieldSchema,
urlFieldSchema,
+3
View File
@@ -2194,6 +2194,9 @@ importers:
'@vitest/ui':
specifier: 'catalog:'
version: 4.1.4(vitest@4.1.4)
lucide-react:
specifier: '*'
version: 0.436.0(react@19.2.6)
tsconfig:
specifier: workspace:*
version: link:../tsconfig
+2
View File
@@ -118,6 +118,7 @@ may_uppercase = [
"IdP",
"Inbucket",
"Index Advisor",
"Integrations",
"IntelliJ",
"Ionic Angular",
"Ionic React",
@@ -172,6 +173,7 @@ may_uppercase = [
"PGAudit",
"pgvector",
"Pandas",
"Partner Catalog",
"PgBouncer",
"Phoenix",
"Pro Plan",