diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example
index 4a4f9e322b..24b69aa89a 100644
--- a/packages/twenty-website/.env.example
+++ b/packages/twenty-website/.env.example
@@ -35,3 +35,8 @@ ENTERPRISE_JWT_PUBLIC_KEY=
# Optional: short-lived validity token length in days (default 30)
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=
+
+# Twenty workspace the partners marketplace reads partner data from
+# (server-side only) via the /s/partners REST endpoint.
+TWENTY_PARTNERS_API_URL=
+TWENTY_PARTNERS_API_KEY=
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx b/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx
new file mode 100644
index 0000000000..6bfd09bb38
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { Container } from '@/design-system/components';
+import type { MarketplacePartner } from '@/lib/partners-api';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+import { filterPartners } from './filter-partners';
+import { useFilterState } from './use-filter-state';
+import { EmptyState } from './components/EmptyState';
+import { FilterBar } from './components/FilterBar';
+import { MarketplaceGrid } from './components/MarketplaceGrid';
+
+const FilterBarOuter = styled.div`
+ background-color: ${theme.colors.primary.background[100]};
+ width: 100%;
+`;
+
+const FilterBarInner = styled(Container)`
+ padding-bottom: ${theme.spacing(6)};
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+ padding-top: ${theme.spacing(6)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-bottom: ${theme.spacing(4)};
+ padding-left: ${theme.spacing(10)};
+ padding-right: ${theme.spacing(10)};
+ padding-top: ${theme.spacing(0)};
+ }
+`;
+
+type MarketplaceClientProps = {
+ partners: readonly MarketplacePartner[];
+};
+
+export function MarketplaceClient({ partners }: MarketplaceClientProps) {
+ const {
+ criteria,
+ toggleRegion,
+ toggleLanguage,
+ toggleDeployment,
+ clearAll,
+ hasAnyFilter,
+ } = useFilterState();
+
+ const filteredPartners = useMemo(
+ () => filterPartners(partners, criteria),
+ [partners, criteria],
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {filteredPartners.length === 0 ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx
new file mode 100644
index 0000000000..0c64a88cba
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx
@@ -0,0 +1,82 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
+
+import type { MarketplacePartner } from '@/lib/partners-api';
+import { PartnerCard } from '../components/PartnerCard';
+
+beforeAll(() => {
+ i18n.load(SOURCE_LOCALE, {});
+ i18n.activate(SOURCE_LOCALE);
+});
+
+const FIXTURE: MarketplacePartner = {
+ slug: 'test-partner',
+ name: 'Test Partner',
+ introduction: 'A reliable partner for testing purposes.',
+ calendarLink: 'https://calendly.com/test-partner',
+ deploymentExpertise: ['CLOUD', 'SELF_HOST'],
+ region: ['EUROPE', 'US'],
+ languagesSpoken: ['ENGLISH', 'FRENCH'],
+};
+
+const renderCard = () =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+describe('PartnerCard', () => {
+ it('renders the partner name as the article heading', () => {
+ const html = renderCard();
+ expect(html).toMatch(new RegExp(`
]*>${FIXTURE.name}
`, 'i'));
+ });
+
+ it('renders the geo eyebrow with the first served region', () => {
+ const html = renderCard();
+ expect(html).toContain(FIXTURE.region[0]);
+ });
+
+ it('renders the full introduction text', () => {
+ const html = renderCard();
+ expect(html).toContain(FIXTURE.introduction);
+ });
+
+ it('renders one chip per value across the three chip rows', () => {
+ const html = renderCard();
+ const expectedChipCount =
+ FIXTURE.region.length +
+ FIXTURE.languagesSpoken.length +
+ FIXTURE.deploymentExpertise.length;
+ const liMatches = html.match(/]*>/g) ?? [];
+ expect(liMatches.length).toBe(expectedChipCount);
+ });
+
+ it('renders the Calendly CTA pointing at the partner link in a new tab', () => {
+ const html = renderCard();
+ expect(html).toContain(`href="${FIXTURE.calendarLink}"`);
+ expect(html).toContain('target="_blank"');
+ expect(html).toContain('noopener');
+ });
+
+ it.each([
+ 'javascript:alert(document.cookie)',
+ 'data:text/html,',
+ 'vbscript:msgbox(1)',
+ '',
+ 'not-a-url',
+ ])('suppresses the CTA when calendarLink is %s', (unsafeLink) => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).not.toContain('href=');
+ });
+});
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts
new file mode 100644
index 0000000000..7bfd65cf79
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts
@@ -0,0 +1,109 @@
+import type { MarketplacePartner } from '@/lib/partners-api';
+
+import {
+ EMPTY_CRITERIA,
+ filterPartners,
+ hasAnyFilter,
+ type FilterCriteria,
+} from '../filter-partners';
+
+const make = (overrides: Partial): MarketplacePartner => ({
+ slug: 'p',
+ name: 'Partner',
+ introduction: 'intro',
+ calendarLink: 'https://calendly.com/p',
+ region: [],
+ languagesSpoken: [],
+ deploymentExpertise: [],
+ ...overrides,
+});
+
+const felix = make({
+ slug: 'felix',
+ region: ['EUROPE', 'US', 'APAC'],
+ languagesSpoken: ['ENGLISH', 'FRENCH'],
+ deploymentExpertise: ['CLOUD', 'SELF_HOST'],
+});
+const rashad = make({
+ slug: 'rashad',
+ region: ['US', 'EUROPE'],
+ languagesSpoken: ['ENGLISH', 'FRENCH'],
+ deploymentExpertise: ['CLOUD'],
+});
+const acme = make({
+ slug: 'acme',
+ region: ['MENA'],
+ languagesSpoken: ['ENGLISH'],
+ deploymentExpertise: ['SELF_HOST'],
+});
+
+const all = [felix, rashad, acme] as const;
+
+describe('filterPartners', () => {
+ it('returns every partner when criteria is empty', () => {
+ expect(filterPartners(all, EMPTY_CRITERIA)).toEqual(all);
+ });
+
+ it('filters by a single region (single value)', () => {
+ const c: FilterCriteria = {
+ regions: new Set(['MENA']),
+ languages: new Set(),
+ deployments: new Set(),
+ };
+ expect(filterPartners(all, c)).toEqual([acme]);
+ });
+
+ it('filters by multiple regions (OR within facet)', () => {
+ const c: FilterCriteria = {
+ regions: new Set(['APAC', 'MENA']),
+ languages: new Set(),
+ deployments: new Set(),
+ };
+ expect(filterPartners(all, c)).toEqual([felix, acme]);
+ });
+
+ it('combines facets with AND', () => {
+ const c: FilterCriteria = {
+ regions: new Set(['EUROPE']),
+ languages: new Set(['FRENCH']),
+ deployments: new Set(),
+ };
+ expect(filterPartners(all, c)).toEqual([felix, rashad]);
+ });
+
+ it('returns empty when no partner matches', () => {
+ const c: FilterCriteria = {
+ regions: new Set(),
+ languages: new Set(['GERMAN']),
+ deployments: new Set(),
+ };
+ expect(filterPartners(all, c)).toEqual([]);
+ });
+
+ it('AND across all three facets', () => {
+ const c: FilterCriteria = {
+ regions: new Set(['EUROPE']),
+ languages: new Set(['FRENCH']),
+ deployments: new Set(['SELF_HOST']),
+ };
+ expect(filterPartners(all, c)).toEqual([felix]);
+ });
+});
+
+describe('hasAnyFilter', () => {
+ it('returns false for empty criteria', () => {
+ expect(hasAnyFilter(EMPTY_CRITERIA)).toBe(false);
+ });
+
+ it('returns true when any facet has a value', () => {
+ expect(
+ hasAnyFilter({ ...EMPTY_CRITERIA, regions: new Set(['EUROPE']) }),
+ ).toBe(true);
+ expect(
+ hasAnyFilter({ ...EMPTY_CRITERIA, languages: new Set(['ENGLISH']) }),
+ ).toBe(true);
+ expect(
+ hasAnyFilter({ ...EMPTY_CRITERIA, deployments: new Set(['CLOUD']) }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts
new file mode 100644
index 0000000000..9dd17cff65
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts
@@ -0,0 +1,104 @@
+import {
+ DEPLOYMENT_EXPERTISES,
+ SERVED_GEOS,
+ SPOKEN_LANGUAGES,
+} from '@/lib/partners-api';
+
+import {
+ buildQueryString,
+ parseCriteriaFromParams,
+ toggleInSet,
+} from '../filter-url-helpers';
+
+describe('parseCriteriaFromParams', () => {
+ it('returns empty sets when params are empty', () => {
+ const c = parseCriteriaFromParams(new URLSearchParams());
+ expect(c.regions.size).toBe(0);
+ expect(c.languages.size).toBe(0);
+ expect(c.deployments.size).toBe(0);
+ });
+
+ it('parses a CSV of valid values', () => {
+ const c = parseCriteriaFromParams(
+ new URLSearchParams(
+ 'regions=EUROPE,US&languages=FRENCH&deployments=CLOUD',
+ ),
+ );
+ expect(c.regions).toEqual(new Set(['EUROPE', 'US']));
+ expect(c.languages).toEqual(new Set(['FRENCH']));
+ expect(c.deployments).toEqual(new Set(['CLOUD']));
+ });
+
+ it('silently drops unknown values', () => {
+ const c = parseCriteriaFromParams(
+ new URLSearchParams('regions=EUROPE,MARS,US&languages=KLINGON'),
+ );
+ expect(c.regions).toEqual(new Set(['EUROPE', 'US']));
+ expect(c.languages.size).toBe(0);
+ });
+
+ it('handles whitespace inside CSV values', () => {
+ const c = parseCriteriaFromParams(
+ new URLSearchParams('regions=EUROPE, US , LATAM'),
+ );
+ expect(c.regions).toEqual(new Set(['EUROPE', 'US', 'LATAM']));
+ });
+});
+
+describe('buildQueryString', () => {
+ it('returns empty string when criteria is empty', () => {
+ expect(
+ buildQueryString({
+ regions: new Set(),
+ languages: new Set(),
+ deployments: new Set(),
+ }),
+ ).toBe('');
+ });
+
+ it('round-trips with parseCriteriaFromParams', () => {
+ const original = {
+ regions: new Set(['EUROPE', 'US']) as ReadonlySet<'EUROPE' | 'US'>,
+ languages: new Set(['FRENCH']) as ReadonlySet<'FRENCH'>,
+ deployments: new Set(['CLOUD']) as ReadonlySet<'CLOUD'>,
+ };
+ const qs = buildQueryString(original as never);
+ const parsed = parseCriteriaFromParams(new URLSearchParams(qs));
+ expect(parsed.regions).toEqual(original.regions);
+ expect(parsed.languages).toEqual(original.languages);
+ expect(parsed.deployments).toEqual(original.deployments);
+ });
+
+ it('omits facets whose sets are empty', () => {
+ const qs = buildQueryString({
+ regions: new Set(['EUROPE']),
+ languages: new Set(),
+ deployments: new Set(),
+ });
+ expect(qs).toBe('regions=EUROPE');
+ });
+});
+
+describe('toggleInSet', () => {
+ it('adds value when absent', () => {
+ expect(toggleInSet(new Set(['A']), 'B')).toEqual(new Set(['A', 'B']));
+ });
+
+ it('removes value when present', () => {
+ expect(toggleInSet(new Set(['A', 'B']), 'B')).toEqual(new Set(['A']));
+ });
+
+ it('does not mutate the original set', () => {
+ const original = new Set(['A']);
+ toggleInSet(original, 'B');
+ expect(original).toEqual(new Set(['A']));
+ });
+});
+
+describe('enum constants are non-empty', () => {
+ it('covers all known regions, languages, deployments', () => {
+ expect(SERVED_GEOS.length).toBe(6);
+ expect(SPOKEN_LANGUAGES.length).toBe(5);
+ expect(DEPLOYMENT_EXPERTISES.length).toBe(2);
+ });
+});
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx
new file mode 100644
index 0000000000..070dfff7ef
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx
@@ -0,0 +1,93 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import { useFilterState } from '../use-filter-state';
+
+let mockSearchParams = new URLSearchParams();
+const mockReplace = jest.fn();
+
+jest.mock('next/navigation', () => ({
+ useSearchParams: () => mockSearchParams,
+ useRouter: () => ({ replace: mockReplace }),
+ usePathname: () => '/en/partners/list',
+}));
+
+type FilterStateResult = ReturnType;
+
+function Capture({ onCapture }: { onCapture: (s: FilterStateResult) => void }) {
+ const state = useFilterState();
+ onCapture(state);
+ return null;
+}
+
+const getState = (): FilterStateResult => {
+ let captured!: FilterStateResult;
+ renderToStaticMarkup(
+ {
+ captured = s;
+ }}
+ />,
+ );
+ return captured;
+};
+
+describe('useFilterState', () => {
+ beforeEach(() => {
+ mockSearchParams = new URLSearchParams();
+ mockReplace.mockReset();
+ });
+
+ it('returns empty criteria and hasAnyFilter=false for empty params', () => {
+ const state = getState();
+ expect(state.hasAnyFilter).toBe(false);
+ expect(state.criteria.regions.size).toBe(0);
+ expect(state.criteria.languages.size).toBe(0);
+ expect(state.criteria.deployments.size).toBe(0);
+ });
+
+ it('parses regions=EUROPE,US into Set {EUROPE, US}', () => {
+ mockSearchParams = new URLSearchParams('regions=EUROPE,US');
+ const state = getState();
+ expect(state.criteria.regions).toEqual(new Set(['EUROPE', 'US']));
+ expect(state.hasAnyFilter).toBe(true);
+ });
+
+ it('silently drops unknown values', () => {
+ mockSearchParams = new URLSearchParams('regions=EUROPE,MARS');
+ const state = getState();
+ expect(state.criteria.regions).toEqual(new Set(['EUROPE']));
+ });
+
+ it('toggleRegion adds a value to the URL when absent', () => {
+ const state = getState();
+ state.toggleRegion('EUROPE');
+ expect(mockReplace).toHaveBeenCalledTimes(1);
+ const [url, options] = mockReplace.mock.calls[0] as [string, unknown];
+ expect(url).toContain('regions=EUROPE');
+ expect(options).toEqual({ scroll: false });
+ });
+
+ it('toggleRegion removes a value from the URL when present', () => {
+ mockSearchParams = new URLSearchParams('regions=EUROPE,US');
+ const state = getState();
+ state.toggleRegion('EUROPE');
+ const [url] = mockReplace.mock.calls[0] as [string, unknown];
+ expect(url).toContain('US');
+ expect(url).not.toMatch(/EUROPE/);
+ });
+
+ it('clearAll navigates to the bare pathname with no query string', () => {
+ mockSearchParams = new URLSearchParams('regions=EUROPE&languages=FRENCH');
+ const state = getState();
+ state.clearAll();
+ expect(mockReplace).toHaveBeenCalledWith('/en/partners/list', {
+ scroll: false,
+ });
+ });
+
+ it('hasAnyFilter is true when languages facet has values', () => {
+ mockSearchParams = new URLSearchParams('languages=FRENCH');
+ const state = getState();
+ expect(state.hasAnyFilter).toBe(true);
+ });
+});
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx
new file mode 100644
index 0000000000..b54786ee85
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import {
+ BaseButton,
+ buttonBaseStyles,
+} from '@/design-system/components/Button/BaseButton';
+import { styled } from '@linaria/react';
+import type { MouseEventHandler, ReactNode } from 'react';
+
+const StyledClearFiltersButton = styled.button`
+ ${buttonBaseStyles}
+`;
+
+type ClearFiltersButtonProps = {
+ children: ReactNode;
+ className?: string;
+ onClick: MouseEventHandler;
+};
+
+export function ClearFiltersButton({
+ children,
+ className,
+ onClick,
+}: ClearFiltersButtonProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx
new file mode 100644
index 0000000000..967653e7f4
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+
+import { theme } from '@/theme';
+
+import { ClearFiltersButton } from './ClearFiltersButton';
+
+type EmptyStateProps = {
+ onClearFilters: () => void;
+};
+
+const Wrapper = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(2)};
+ padding: ${theme.spacing(12)} ${theme.spacing(4)};
+ text-align: center;
+`;
+
+const Heading = styled.h2`
+ color: ${theme.colors.primary.text[100]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4.5)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0;
+ line-height: ${theme.lineHeight(6)};
+ margin: 0;
+`;
+
+const Subtitle = styled.p`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ line-height: ${theme.lineHeight(5.5)};
+ margin: 0;
+`;
+
+const EmptyStateClearButton = styled(ClearFiltersButton)`
+ margin-top: ${theme.spacing(4)};
+`;
+
+export function EmptyState({ onClearFilters }: EmptyStateProps) {
+ const { i18n } = useLingui();
+
+ return (
+
+ {i18n._(msg`No partners match your filters`)}
+
+ {i18n._(msg`Try removing some filters or browse all partners.`)}
+
+
+ {i18n._(msg`Clear filters`)}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx
new file mode 100644
index 0000000000..b64a7c0f78
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+
+import {
+ DEPLOYMENT_EXPERTISES,
+ SERVED_GEOS,
+ SPOKEN_LANGUAGES,
+ type DeploymentExpertise,
+ type ServedGeo,
+ type SpokenLanguage,
+} from '@/lib/partners-api';
+import { theme } from '@/theme';
+
+import type { FilterCriteria } from '../filter-partners';
+import {
+ DEPLOYMENT_EXPERTISE_LABELS,
+ SERVED_GEO_LABELS,
+ SPOKEN_LANGUAGE_LABELS,
+} from './chip-labels';
+import { ClearFiltersButton } from './ClearFiltersButton';
+import { FilterChipRow } from './FilterChipRow';
+
+type FilterBarProps = {
+ criteria: FilterCriteria;
+ totalCount: number;
+ visibleCount: number;
+ hasAnyFilter: boolean;
+ onToggleRegion: (geo: ServedGeo) => void;
+ onToggleLanguage: (lang: SpokenLanguage) => void;
+ onToggleDeployment: (dep: DeploymentExpertise) => void;
+ onClearAll: () => void;
+};
+
+const BarSection = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(4)};
+`;
+
+const Footer = styled.div`
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(3)};
+ justify-content: space-between;
+ padding-top: ${theme.spacing(2)};
+`;
+
+const ResultCount = styled.p`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(3)};
+ line-height: ${theme.lineHeight(4)};
+ margin: 0;
+`;
+
+export function FilterBar({
+ criteria,
+ totalCount,
+ visibleCount,
+ hasAnyFilter,
+ onToggleRegion,
+ onToggleLanguage,
+ onToggleDeployment,
+ onClearAll,
+}: FilterBarProps) {
+ const { i18n } = useLingui();
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx
new file mode 100644
index 0000000000..6428790e65
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import type { MessageDescriptor } from '@lingui/core';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+
+import { theme } from '@/theme';
+
+import { chipBaseStyles } from './chip-styles';
+
+type FilterChipRowProps = {
+ label: MessageDescriptor;
+ values: readonly T[];
+ valueLabels: Record;
+ selected: ReadonlySet;
+ onToggle: (value: T) => void;
+};
+
+const Section = styled.section`
+ align-items: baseline;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(2)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ flex-direction: row;
+ gap: ${theme.spacing(4)};
+ }
+`;
+
+const RowLabel = styled.h3`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.08em;
+ line-height: ${theme.lineHeight(4)};
+ margin: 0;
+ min-width: 80px;
+ text-transform: uppercase;
+`;
+
+const ChipList = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(2)};
+`;
+
+const Chip = styled.button`
+ cursor: pointer;
+
+ &[aria-pressed='true'] {
+ background-color: ${theme.colors.primary.text[100]};
+ border-color: ${theme.colors.primary.text[100]};
+ color: ${theme.colors.primary.background[100]};
+ }
+
+ &:hover:not([aria-pressed='true']) {
+ background-color: rgba(0, 0, 0, 0.04);
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colors.primary.border[40]};
+ outline-offset: 2px;
+ }
+`;
+
+export function FilterChipRow({
+ label,
+ values,
+ valueLabels,
+ selected,
+ onToggle,
+}: FilterChipRowProps) {
+ const { i18n } = useLingui();
+
+ return (
+
+ {i18n._(label)}
+
+ {values.map((value) => {
+ const isPressed = selected.has(value);
+ return (
+ onToggle(value)}
+ >
+ {i18n._(valueLabels[value])}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx
new file mode 100644
index 0000000000..40a36b4e10
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx
@@ -0,0 +1,58 @@
+import { Container } from '@/design-system/components';
+import type { MarketplacePartner } from '@/lib/partners-api';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+import { PartnerCard } from './PartnerCard';
+
+const Section = styled.section`
+ background-color: ${theme.colors.primary.background[100]};
+ min-width: 0;
+ width: 100%;
+`;
+
+const StyledContainer = styled(Container)`
+ padding-bottom: ${theme.spacing(28)};
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+ padding-top: ${theme.spacing(4)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ padding-bottom: ${theme.spacing(36)};
+ padding-left: ${theme.spacing(10)};
+ padding-right: ${theme.spacing(10)};
+ }
+`;
+
+const CardGrid = styled.div`
+ display: grid;
+ gap: ${theme.spacing(6)};
+ grid-template-columns: 1fr;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: ${theme.spacing(8)};
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: ${theme.breakpoints.lg}px) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+`;
+
+type MarketplaceGridProps = {
+ partners: readonly MarketplacePartner[];
+};
+
+export function MarketplaceGrid({ partners }: MarketplaceGridProps) {
+ return (
+
+
+
+ {partners.map((partner, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx
new file mode 100644
index 0000000000..a8d9feded4
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx
@@ -0,0 +1,65 @@
+import { msg } from '@lingui/core/macro';
+import {
+ Body,
+ Container,
+ Eyebrow,
+ Heading,
+ HeadingPart,
+} from '@/design-system/components';
+import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+const Section = styled.section`
+ background-color: ${theme.colors.primary.background[100]};
+ width: 100%;
+`;
+
+const StyledContainer = styled(Container)`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(4)};
+ padding-bottom: ${theme.spacing(10)};
+ padding-left: ${theme.spacing(4)};
+ padding-right: ${theme.spacing(4)};
+ padding-top: ${theme.spacing(20)};
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ gap: ${theme.spacing(6)};
+ padding-bottom: ${theme.spacing(14)};
+ padding-left: ${theme.spacing(10)};
+ padding-right: ${theme.spacing(10)};
+ padding-top: ${theme.spacing(28)};
+ }
+`;
+
+const HeaderBody = styled(Body)`
+ max-width: 640px;
+`;
+
+export function MarketplaceHeader() {
+ const i18n = getServerI18n();
+
+ return (
+
+
+
+
+ {i18n._(msg`Marketplace`)}
+
+
+
+ {i18n._(msg`Find your`)}{' '}
+
+ {i18n._(msg`Twenty partner`)}
+
+
+
+ {i18n._(
+ msg`Twenty's certified partners help teams migrate, customise, and operate the open source CRM across regions, languages, and deployment models. Browse profiles and book a call.`,
+ )}
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx
new file mode 100644
index 0000000000..733120c339
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx
@@ -0,0 +1,53 @@
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+const AVATAR_PALETTE = [
+ theme.colors.accent.blue[100],
+ theme.colors.accent.pink[100],
+ theme.colors.accent.green[100],
+];
+
+const AvatarBlock = styled.span`
+ align-items: center;
+ border-radius: ${theme.radius(1.5)};
+ color: ${theme.colors.secondary.text[100]};
+ display: inline-flex;
+ flex-shrink: 0;
+ font-family: ${theme.font.family.serif};
+ font-size: ${theme.font.size(5)};
+ font-weight: ${theme.font.weight.light};
+ height: 48px;
+ justify-content: center;
+ letter-spacing: -0.02em;
+ line-height: 1;
+ width: 48px;
+`;
+
+function pickPaletteColor(slug: string): string {
+ if (slug.length === 0) return AVATAR_PALETTE[0];
+ const index = (slug.charCodeAt(0) + slug.length) % AVATAR_PALETTE.length;
+ return AVATAR_PALETTE[index];
+}
+
+function pickInitials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return '?';
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
+ return (words[0][0] + words[1][0]).toUpperCase();
+}
+
+type PartnerAvatarProps = {
+ name: string;
+ slug: string;
+};
+
+export function PartnerAvatar({ name, slug }: PartnerAvatarProps) {
+ const backgroundColor = pickPaletteColor(slug);
+ const initials = pickInitials(name);
+
+ return (
+
+ {initials}
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx
new file mode 100644
index 0000000000..193be337e3
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { LinkButton } from '@/design-system/components';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+import type { CSSProperties } from 'react';
+
+import type { MarketplacePartner } from '@/lib/partners-api';
+import { PartnerAvatar } from './PartnerAvatar';
+import { PartnerChipRow } from './PartnerChipRow';
+import {
+ DEPLOYMENT_EXPERTISE_LABELS,
+ SERVED_GEO_LABELS,
+ SPOKEN_LANGUAGE_LABELS,
+} from './chip-labels';
+
+type PartnerCardStyle = CSSProperties & {
+ '--partner-card-index': number;
+};
+
+const CardArticle = styled.article`
+ @keyframes partnerCardEnter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 18px, 0);
+ }
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+ }
+
+ animation: partnerCardEnter 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
+ animation-delay: calc(var(--partner-card-index) * 90ms + 180ms);
+ background-color: ${theme.colors.primary.background[100]};
+ border: 1px solid ${theme.colors.primary.border[10]};
+ border-radius: ${theme.radius(2)};
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(5)};
+ padding: ${theme.spacing(6)};
+ transition:
+ border-color 0.25s ease,
+ box-shadow 0.25s ease,
+ transform 0.25s ease;
+ will-change: transform;
+
+ &:hover {
+ border-color: ${theme.colors.primary.border[20]};
+ box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
+ transform: translateY(-2px);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ transition: none;
+
+ &:hover {
+ transform: none;
+ }
+ }
+`;
+
+const CardHeader = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${theme.spacing(4)};
+`;
+
+const HeaderText = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(1)};
+ min-width: 0;
+`;
+
+const PartnerName = styled.h3`
+ color: ${theme.colors.primary.text[100]};
+ font-family: ${theme.font.family.serif};
+ font-size: ${theme.font.size(6)};
+ font-weight: ${theme.font.weight.light};
+ letter-spacing: -0.02em;
+ line-height: ${theme.lineHeight(7)};
+ margin: 0;
+`;
+
+const CountryEyebrow = styled.span`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.08em;
+ line-height: ${theme.lineHeight(4)};
+ text-transform: uppercase;
+`;
+
+const Introduction = styled.p`
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ color: ${theme.colors.primary.text[60]};
+ display: -webkit-box;
+ font-family: ${theme.font.family.sans};
+ font-size: ${theme.font.size(4)};
+ line-height: ${theme.lineHeight(5.5)};
+ margin: 0;
+ overflow: hidden;
+`;
+
+const Divider = styled.hr`
+ background-color: ${theme.colors.primary.border[10]};
+ border: 0;
+ height: 1px;
+ margin: 0;
+ width: 100%;
+`;
+
+const ChipRows = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(3)};
+`;
+
+const CtaWrapper = styled.div`
+ display: flex;
+ margin-top: auto;
+`;
+
+const isSafeHttpUrl = (raw: string) => {
+ try {
+ return ['https:', 'http:'].includes(new URL(raw).protocol);
+ } catch {
+ return false;
+ }
+};
+
+type PartnerCardProps = {
+ partner: MarketplacePartner;
+ index: number;
+};
+
+export function PartnerCard({ partner, index }: PartnerCardProps) {
+ const { i18n } = useLingui();
+ const headingId = `partner-card-heading-${partner.slug}`;
+ const style: PartnerCardStyle = { '--partner-card-index': index };
+
+ const firstGeo = partner.region[0];
+ const countryLine = firstGeo
+ ? i18n._(SERVED_GEO_LABELS[firstGeo]).toUpperCase()
+ : '';
+
+ return (
+
+
+
+
+ {partner.name}
+ {countryLine}
+
+
+
+ {partner.introduction}
+
+
+
+
+
+
+
+
+
+ {isSafeHttpUrl(partner.calendarLink) && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx
new file mode 100644
index 0000000000..a7eeb6458b
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import type { MessageDescriptor } from '@lingui/core';
+import { useLingui } from '@lingui/react';
+import { theme } from '@/theme';
+import { styled } from '@linaria/react';
+
+import { chipBaseStyles } from './chip-styles';
+
+const Row = styled.dl`
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.spacing(2)};
+ margin: 0;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ align-items: center;
+ flex-direction: row;
+ gap: ${theme.spacing(4)};
+ }
+`;
+
+const RowLabel = styled.dt`
+ color: ${theme.colors.primary.text[60]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.08em;
+ line-height: ${theme.lineHeight(4)};
+ margin: 0;
+ text-transform: uppercase;
+
+ @media (min-width: ${theme.breakpoints.md}px) {
+ flex-shrink: 0;
+ width: 80px;
+ }
+`;
+
+const ChipListWrapper = styled.dd`
+ margin: 0;
+`;
+
+const ChipList = styled.ul`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${theme.spacing(2)};
+ list-style: none;
+ margin: 0;
+ padding: 0;
+`;
+
+type PartnerChipRowProps = {
+ label: MessageDescriptor;
+ values: readonly TValue[];
+ valueLabels: Record;
+};
+
+export function PartnerChipRow({
+ label,
+ values,
+ valueLabels,
+}: PartnerChipRowProps) {
+ const { i18n } = useLingui();
+
+ return (
+
+ {i18n._(label)}
+
+
+ {values.map((value) => (
+
+ {i18n._(valueLabels[value])}
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts
new file mode 100644
index 0000000000..1b7b6cf80b
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts
@@ -0,0 +1,34 @@
+import { msg } from '@lingui/core/macro';
+import type { MessageDescriptor } from '@lingui/core';
+
+import type {
+ DeploymentExpertise,
+ ServedGeo,
+ SpokenLanguage,
+} from '@/lib/partners-api';
+
+export const SERVED_GEO_LABELS: Record = {
+ EUROPE: msg`Europe`,
+ US: msg`US`,
+ LATAM: msg`LATAM`,
+ MENA: msg`MENA`,
+ APAC: msg`APAC`,
+ AFRICA: msg`Africa`,
+};
+
+export const SPOKEN_LANGUAGE_LABELS: Record =
+ {
+ ENGLISH: msg`English`,
+ FRENCH: msg`French`,
+ GERMAN: msg`German`,
+ CHINESE: msg`Chinese`,
+ SPANISH: msg`Spanish`,
+ };
+
+export const DEPLOYMENT_EXPERTISE_LABELS: Record<
+ DeploymentExpertise,
+ MessageDescriptor
+> = {
+ CLOUD: msg`Cloud`,
+ SELF_HOST: msg`Self-host`,
+};
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts
new file mode 100644
index 0000000000..fb6f616dc5
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts
@@ -0,0 +1,16 @@
+import { css } from '@linaria/core';
+
+import { theme } from '@/theme';
+
+export const chipBaseStyles = css`
+ border: 1px solid ${theme.colors.primary.border[10]};
+ border-radius: ${theme.radius(4)};
+ color: ${theme.colors.primary.text[80]};
+ font-family: ${theme.font.family.mono};
+ font-size: ${theme.font.size(3)};
+ font-weight: ${theme.font.weight.medium};
+ letter-spacing: 0.04em;
+ line-height: ${theme.lineHeight(4)};
+ padding: ${theme.spacing(1)} ${theme.spacing(2.5)};
+ text-transform: uppercase;
+`;
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts
new file mode 100644
index 0000000000..77902d5da0
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts
@@ -0,0 +1,6 @@
+export { MarketplaceGrid } from './MarketplaceGrid';
+export { MarketplaceHeader } from './MarketplaceHeader';
+export { PartnerCard } from './PartnerCard';
+export { EmptyState } from './EmptyState';
+export { FilterBar } from './FilterBar';
+export { FilterChipRow } from './FilterChipRow';
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts b/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts
new file mode 100644
index 0000000000..c0431382ad
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts
@@ -0,0 +1,39 @@
+import type {
+ DeploymentExpertise,
+ MarketplacePartner,
+ ServedGeo,
+ SpokenLanguage,
+} from '@/lib/partners-api';
+
+export type FilterCriteria = {
+ regions: ReadonlySet;
+ languages: ReadonlySet;
+ deployments: ReadonlySet;
+};
+
+export const EMPTY_CRITERIA: FilterCriteria = {
+ regions: new Set(),
+ languages: new Set(),
+ deployments: new Set(),
+};
+
+const facetMatches = (
+ values: readonly T[],
+ selected: ReadonlySet,
+): boolean => selected.size === 0 || values.some((v) => selected.has(v));
+
+export const filterPartners = (
+ partners: readonly MarketplacePartner[],
+ criteria: FilterCriteria,
+): readonly MarketplacePartner[] =>
+ partners.filter(
+ (p) =>
+ facetMatches(p.region, criteria.regions) &&
+ facetMatches(p.languagesSpoken, criteria.languages) &&
+ facetMatches(p.deploymentExpertise, criteria.deployments),
+ );
+
+export const hasAnyFilter = (criteria: FilterCriteria): boolean =>
+ criteria.regions.size > 0 ||
+ criteria.languages.size > 0 ||
+ criteria.deployments.size > 0;
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts b/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts
new file mode 100644
index 0000000000..5ba11753f5
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts
@@ -0,0 +1,52 @@
+import {
+ DEPLOYMENT_EXPERTISES,
+ SERVED_GEOS,
+ SPOKEN_LANGUAGES,
+} from '@/lib/partners-api';
+
+import type { FilterCriteria } from './filter-partners';
+
+const parseFacet = (
+ raw: string | null,
+ allowed: readonly T[],
+): ReadonlySet => {
+ if (!raw) return new Set();
+ const allowedSet = new Set(allowed);
+ const parsed = raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s): s is T => allowedSet.has(s));
+ return new Set(parsed);
+};
+
+export const parseCriteriaFromParams = (
+ params: URLSearchParams,
+): FilterCriteria => ({
+ regions: parseFacet(params.get('regions'), SERVED_GEOS),
+ languages: parseFacet(params.get('languages'), SPOKEN_LANGUAGES),
+ deployments: parseFacet(params.get('deployments'), DEPLOYMENT_EXPERTISES),
+});
+
+const encodeFacet = (set: ReadonlySet): string | null =>
+ set.size === 0 ? null : Array.from(set).sort().join(',');
+
+export const buildQueryString = (criteria: FilterCriteria): string => {
+ const params = new URLSearchParams();
+ const r = encodeFacet(criteria.regions);
+ const l = encodeFacet(criteria.languages);
+ const d = encodeFacet(criteria.deployments);
+ if (r) params.set('regions', r);
+ if (l) params.set('languages', l);
+ if (d) params.set('deployments', d);
+ return params.toString();
+};
+
+export const toggleInSet = (
+ set: ReadonlySet,
+ value: T,
+): ReadonlySet => {
+ const next = new Set(set);
+ if (next.has(value)) next.delete(value);
+ else next.add(value);
+ return next;
+};
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/page.tsx b/packages/twenty-website/src/app/[locale]/partners/list/page.tsx
new file mode 100644
index 0000000000..eddfe0faab
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/page.tsx
@@ -0,0 +1,54 @@
+import { Suspense } from 'react';
+
+import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
+import {
+ getRouteI18n,
+ type LocaleRouteParams,
+} from '@/lib/i18n/utils/get-route-i18n';
+import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
+import { Menu, MENU_DATA } from '@/sections/Menu';
+import { theme } from '@/theme';
+import { buildRouteMetadata } from '@/lib/seo';
+import { getPartners } from '@/lib/partners-api/get-partners';
+
+import { MarketplaceHeader } from './components';
+import { MarketplaceClient } from './MarketplaceClient';
+
+export const generateMetadata = buildRouteMetadata('partnersList');
+
+type PartnersMarketplacePageProps = {
+ params: Promise;
+};
+
+export default async function PartnersMarketplacePage({
+ params,
+}: PartnersMarketplacePageProps) {
+ const [, stats, livePartners] = await Promise.all([
+ getRouteI18n(params),
+ fetchCommunityStats(),
+ getPartners(),
+ ]);
+ const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts b/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts
new file mode 100644
index 0000000000..40b8fbd61e
--- /dev/null
+++ b/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts
@@ -0,0 +1,96 @@
+'use client';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useCallback, useMemo } from 'react';
+
+import type {
+ DeploymentExpertise,
+ ServedGeo,
+ SpokenLanguage,
+} from '@/lib/partners-api';
+
+import {
+ type FilterCriteria,
+ hasAnyFilter as computeHasAnyFilter,
+} from './filter-partners';
+import {
+ buildQueryString,
+ parseCriteriaFromParams,
+ toggleInSet,
+} from './filter-url-helpers';
+
+type UseFilterStateReturn = {
+ criteria: FilterCriteria;
+ toggleRegion: (geo: ServedGeo) => void;
+ toggleLanguage: (lang: SpokenLanguage) => void;
+ toggleDeployment: (dep: DeploymentExpertise) => void;
+ clearAll: () => void;
+ hasAnyFilter: boolean;
+};
+
+export const useFilterState = (): UseFilterStateReturn => {
+ const params = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const criteria = useMemo(
+ () => parseCriteriaFromParams(new URLSearchParams(params.toString())),
+ [params],
+ );
+
+ const writeCriteria = useCallback(
+ (next: FilterCriteria) => {
+ const qs = buildQueryString(next);
+ const url = qs ? `${pathname}?${qs}` : pathname;
+ router.replace(url, { scroll: false });
+ },
+ [pathname, router],
+ );
+
+ const toggleRegion = useCallback(
+ (geo: ServedGeo) => {
+ writeCriteria({
+ ...criteria,
+ regions: toggleInSet(criteria.regions, geo),
+ });
+ },
+ [criteria, writeCriteria],
+ );
+
+ const toggleLanguage = useCallback(
+ (lang: SpokenLanguage) => {
+ writeCriteria({
+ ...criteria,
+ languages: toggleInSet(criteria.languages, lang),
+ });
+ },
+ [criteria, writeCriteria],
+ );
+
+ const toggleDeployment = useCallback(
+ (dep: DeploymentExpertise) => {
+ writeCriteria({
+ ...criteria,
+ deployments: toggleInSet(criteria.deployments, dep),
+ });
+ },
+ [criteria, writeCriteria],
+ );
+
+ const clearAll = useCallback(() => {
+ writeCriteria({
+ regions: new Set(),
+ languages: new Set(),
+ deployments: new Set(),
+ });
+ }, [writeCriteria]);
+
+ return {
+ criteria,
+ toggleRegion,
+ toggleLanguage,
+ toggleDeployment,
+ clearAll,
+ hasAnyFilter: computeHasAnyFilter(criteria),
+ };
+};
diff --git a/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts b/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts
new file mode 100644
index 0000000000..f6dd6b5b3c
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts
@@ -0,0 +1,84 @@
+import { partnersApiFetch } from '@/lib/partners-api/client';
+
+describe('partnersApiFetch', () => {
+ const ORIGINAL_ENV = process.env;
+
+ beforeEach(() => {
+ process.env = {
+ ...ORIGINAL_ENV,
+ TWENTY_PARTNERS_API_URL: 'https://twenty.example.com',
+ TWENTY_PARTNERS_API_KEY: 'test-key-123',
+ };
+ });
+
+ afterEach(() => {
+ process.env = ORIGINAL_ENV;
+ jest.restoreAllMocks();
+ });
+
+ it('throws a clear error when TWENTY_PARTNERS_API_URL is missing', async () => {
+ delete process.env.TWENTY_PARTNERS_API_URL;
+
+ await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
+ /TWENTY_PARTNERS_API_URL/,
+ );
+ });
+
+ it('throws a clear error when TWENTY_PARTNERS_API_KEY is missing', async () => {
+ delete process.env.TWENTY_PARTNERS_API_KEY;
+
+ await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
+ /TWENTY_PARTNERS_API_KEY/,
+ );
+ });
+
+ it('sends Authorization: Bearer header and returns parsed JSON on 2xx', async () => {
+ const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ );
+
+ const result = await partnersApiFetch('/rest/people');
+
+ expect(result).toEqual({ ok: true });
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchSpy.mock.calls[0];
+ expect(url).toBe('https://twenty.example.com/rest/people');
+ expect((init as RequestInit).headers).toMatchObject({
+ Authorization: 'Bearer test-key-123',
+ Accept: 'application/json',
+ });
+ expect((init as RequestInit).cache).toBe('no-store');
+ });
+
+ it('throws on non-2xx with status, path, and body snippet', async () => {
+ jest
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('Workspace not found', { status: 404 }));
+
+ await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
+ /404.*\/rest\/people.*Workspace not found/s,
+ );
+ });
+
+ it('lets the caller override cache and merges headers', async () => {
+ const fetchSpy = jest
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(new Response('{}', { status: 200 }));
+
+ await partnersApiFetch('/rest/people', {
+ cache: 'force-cache',
+ headers: { 'X-Custom': 'yes' },
+ });
+
+ const init = fetchSpy.mock.calls[0][1] as RequestInit;
+ expect(init.cache).toBe('force-cache');
+ expect(init.headers).toMatchObject({
+ Authorization: 'Bearer test-key-123',
+ Accept: 'application/json',
+ 'x-custom': 'yes',
+ });
+ });
+});
diff --git a/packages/twenty-website/src/lib/partners-api/client.ts b/packages/twenty-website/src/lib/partners-api/client.ts
new file mode 100644
index 0000000000..29b494fb79
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/client.ts
@@ -0,0 +1,37 @@
+export const partnersApiFetch = async (
+ path: string,
+ init: RequestInit = {},
+): Promise => {
+ const baseUrl = process.env.TWENTY_PARTNERS_API_URL;
+ const apiKey = process.env.TWENTY_PARTNERS_API_KEY;
+ if (!baseUrl) {
+ throw new Error('Missing TWENTY_PARTNERS_API_URL env var');
+ }
+ if (!apiKey) {
+ throw new Error('Missing TWENTY_PARTNERS_API_KEY env var');
+ }
+
+ const callerHeaders = Object.fromEntries(
+ new Headers(init.headers ?? {}).entries(),
+ );
+
+ const base = baseUrl.replace(/\/$/, '');
+ const response = await fetch(`${base}${path}`, {
+ cache: 'no-store',
+ ...init,
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json',
+ ...callerHeaders,
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(
+ `Twenty API request failed: ${response.status} ${path} — ${body.slice(0, 500)}`,
+ );
+ }
+
+ return response.json();
+};
diff --git a/packages/twenty-website/src/lib/partners-api/get-partners.ts b/packages/twenty-website/src/lib/partners-api/get-partners.ts
new file mode 100644
index 0000000000..66c20d280f
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/get-partners.ts
@@ -0,0 +1,44 @@
+import { partnersApiFetch } from './client';
+import type { MarketplacePartner } from './partner-types';
+
+type ApiPartner = {
+ id: string;
+ name: string;
+ slug: string;
+ introduction: string;
+ languagesSpoken: MarketplacePartner['languagesSpoken'][number][];
+ deploymentExpertise: MarketplacePartner['deploymentExpertise'][number][];
+ region: MarketplacePartner['region'][number][];
+ calendarLink: { primaryLinkUrl: string | null } | null;
+};
+
+type ApiResponse = { ok: boolean; count: number; partners: ApiPartner[] };
+
+// Bare domains stored in the CRM (e.g. "calendly.com/x") lack a scheme.
+// Prepend https:// so the URL is absolute; isSafeHttpUrl in PartnerCard will
+// still reject anything that doesn't parse as a valid http(s) URL.
+const normalizeUrl = (raw: string): string =>
+ raw && !raw.includes('://') ? `https://${raw}` : raw;
+
+export const getPartners = async (): Promise => {
+ try {
+ const data = (await partnersApiFetch('/s/partners')) as ApiResponse;
+ if (!Array.isArray(data.partners)) {
+ throw new Error(
+ '[partners-api] Unexpected API shape: missing partners array',
+ );
+ }
+ return data.partners.map((p) => ({
+ slug: p.slug,
+ name: p.name,
+ introduction: p.introduction,
+ languagesSpoken: p.languagesSpoken,
+ deploymentExpertise: p.deploymentExpertise,
+ region: p.region,
+ calendarLink: normalizeUrl(p.calendarLink?.primaryLinkUrl ?? ''),
+ }));
+ } catch (error) {
+ console.error('[partners-api] getPartners failed:', error);
+ return [];
+ }
+};
diff --git a/packages/twenty-website/src/lib/partners-api/index.ts b/packages/twenty-website/src/lib/partners-api/index.ts
new file mode 100644
index 0000000000..e8c9c9ac1c
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/index.ts
@@ -0,0 +1,11 @@
+export {
+ DEPLOYMENT_EXPERTISES,
+ SERVED_GEOS,
+ SPOKEN_LANGUAGES,
+} from '@/lib/partners-api/partner-facets';
+export type {
+ DeploymentExpertise,
+ ServedGeo,
+ SpokenLanguage,
+} from '@/lib/partners-api/partner-facets';
+export type { MarketplacePartner } from '@/lib/partners-api/partner-types';
diff --git a/packages/twenty-website/src/lib/partners-api/partner-facets.ts b/packages/twenty-website/src/lib/partners-api/partner-facets.ts
new file mode 100644
index 0000000000..bb64248757
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/partner-facets.ts
@@ -0,0 +1,25 @@
+// Facet vocabularies are the single source of truth for partner filtering:
+// the `as const` arrays drive both the runtime filter options and the union
+// types derived from them, so the two can never drift out of sync.
+
+export const SERVED_GEOS = [
+ 'EUROPE',
+ 'US',
+ 'LATAM',
+ 'MENA',
+ 'APAC',
+ 'AFRICA',
+] as const;
+export type ServedGeo = (typeof SERVED_GEOS)[number];
+
+export const SPOKEN_LANGUAGES = [
+ 'ENGLISH',
+ 'FRENCH',
+ 'GERMAN',
+ 'CHINESE',
+ 'SPANISH',
+] as const;
+export type SpokenLanguage = (typeof SPOKEN_LANGUAGES)[number];
+
+export const DEPLOYMENT_EXPERTISES = ['CLOUD', 'SELF_HOST'] as const;
+export type DeploymentExpertise = (typeof DEPLOYMENT_EXPERTISES)[number];
diff --git a/packages/twenty-website/src/lib/partners-api/partner-types.ts b/packages/twenty-website/src/lib/partners-api/partner-types.ts
new file mode 100644
index 0000000000..0d6a900394
--- /dev/null
+++ b/packages/twenty-website/src/lib/partners-api/partner-types.ts
@@ -0,0 +1,15 @@
+import type {
+ DeploymentExpertise,
+ ServedGeo,
+ SpokenLanguage,
+} from './partner-facets';
+
+export type MarketplacePartner = {
+ slug: string;
+ name: string;
+ introduction: string;
+ calendarLink: string;
+ deploymentExpertise: readonly DeploymentExpertise[];
+ region: readonly ServedGeo[];
+ languagesSpoken: readonly SpokenLanguage[];
+};
diff --git a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts
index be73fe438c..a241bf11ca 100644
--- a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts
+++ b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts
@@ -48,6 +48,15 @@ export const STATIC_WEBSITE_ROUTES = [
priority: 0.7,
indexed: true,
},
+ {
+ id: 'partnersList',
+ path: '/partners/list',
+ title: msg`Find a Twenty Partner — Certified Open Source CRM Implementers`,
+ description: msg`Browse Twenty's certified partner network: regional coverage, languages, deployment expertise, and direct booking links.`,
+ changeFrequency: 'weekly',
+ priority: 0.7,
+ indexed: true,
+ },
{
id: 'releases',
path: '/releases',
diff --git a/packages/twenty-website/src/lib/website-routing/types.ts b/packages/twenty-website/src/lib/website-routing/types.ts
index ed77a2a1cd..272b7228b8 100644
--- a/packages/twenty-website/src/lib/website-routing/types.ts
+++ b/packages/twenty-website/src/lib/website-routing/types.ts
@@ -8,6 +8,7 @@ export type WebsiteRouteId =
| 'product'
| 'pricing'
| 'partners'
+ | 'partnersList'
| 'releases'
| 'customers'
| 'articles'