diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
index c8f51ea1ce1..3b373807a1b 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
@@ -28,6 +28,22 @@ const allIntegrations = INTEGRATIONS
const INTEGRATION_COUNT = allIntegrations.length
const baseUrl = SITE_URL
+/**
+ * High-connectivity integrations (e.g. Slack, used as a notification target
+ * across hundreds of unrelated templates via `alsoIntegrations`) can match
+ * many hundreds of templates, ballooning this page's HTML/RSC payload well
+ * past Googlebot's 2MB crawl limit. Cap to a bounded, still-generous set,
+ * consistent with the related-integrations section's own cap below.
+ *
+ * `getTemplatesForBlock` returns matches in registry insertion order, not
+ * relevance - sorted `featured` first, then owned-by-the-viewing-integration,
+ * so the cap can't hide a curated `featured` template (owned or reached via
+ * `alsoIntegrations`) behind a larger pile of non-featured owned templates,
+ * nor behind unrelated `alsoIntegrations` matches that happen to iterate
+ * earlier in the registry.
+ */
+const MAX_TEMPLATES_SHOWN = 12
+
/** Fast O(1) lookups - avoids repeated linear scans inside render loops. */
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
const byType = new Map(allIntegrations.map((i) => [i.type, i]))
@@ -380,6 +396,12 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
relatedIntegrations.map((i) => i.name)
)
const matchingTemplates = getTemplatesForBlock(integration.type)
+ .sort(
+ (a, b) =>
+ Number(b.featured ?? false) - Number(a.featured ?? false) ||
+ Number(b.isOwner) - Number(a.isOwner)
+ )
+ .slice(0, MAX_TEMPLATES_SHOWN)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
index 4b272a6943a..c2a5dfd1356 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
@@ -7,6 +7,7 @@ import {
INTEGRATIONS,
type Integration,
POPULAR_WORKFLOWS,
+ toIntegrationSummary,
} from '@/lib/integrations'
import { withFilteredNoindex } from '@/lib/landing/seo'
import { JsonLd } from '@/app/(landing)/components/json-ld'
@@ -17,6 +18,7 @@ import { RequestIntegrationModal } from '@/app/(landing)/integrations/components
import { integrationsSearchParamsCache } from '@/app/(landing)/integrations/search-params'
const allIntegrations = INTEGRATIONS
+const integrationSummaries = allIntegrations.map(toIntegrationSummary)
const INTEGRATION_COUNT = allIntegrations.length
const OAUTH_COUNT = allIntegrations.filter((i) => i.authType === 'oauth').length
const TRIGGER_INTEGRATION_COUNT = allIntegrations.filter((i) => i.triggerCount > 0).length
@@ -75,9 +77,9 @@ const INTEGRATIONS_BREADCRUMB_JSON_LD = {
const FEATURED_SLUGS = ['slack', 'notion', 'github', 'gmail'] as const
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
-const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
- (i): i is Integration => i !== undefined
-)
+const featured = FEATURED_SLUGS.map((s) => bySlug.get(s))
+ .filter((i): i is Integration => i !== undefined)
+ .map(toIntegrationSummary)
/**
* `q`/`category` render a genuinely different server-rendered list (see
@@ -215,7 +217,7 @@ export default async function IntegrationsPage({
All Integrations
-
+
{/* FAQ */}
diff --git a/apps/sim/app/(landing)/integrations/components/integration-card.tsx b/apps/sim/app/(landing)/integrations/components/integration-card.tsx
index 3749dd00a94..e7b2197cc41 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-card.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-card.tsx
@@ -1,14 +1,14 @@
import type { ComponentType, SVGProps } from 'react'
import { memo } from 'react'
import Link from 'next/link'
-import type { Integration } from '@/lib/integrations'
+import type { IntegrationSummary } from '@/lib/integrations'
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
const HOVER_BG = 'transition-colors hover:bg-[var(--surface-hover)]' as const
interface IntegrationItemProps {
- integration: Integration
+ integration: IntegrationSummary
IconComponent?: ComponentType>
}
diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
index a226fec75cc..5519b97313b 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
@@ -3,7 +3,11 @@
import { useMemo } from 'react'
import { ChipInput, Search } from '@sim/emcn'
import { debounce, useQueryStates } from 'nuqs'
-import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
+import {
+ blockTypeToIconMap,
+ formatIntegrationType,
+ type IntegrationSummary,
+} from '@/lib/integrations'
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
import {
integrationsParsers,
@@ -19,7 +23,7 @@ const PILL_ACTIVE = 'bg-[var(--surface-active)]' as const
const PILL_INACTIVE = 'hover:bg-[var(--surface-hover)]' as const
interface IntegrationGridProps {
- integrations: readonly Integration[]
+ integrations: readonly IntegrationSummary[]
}
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
@@ -29,30 +33,17 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
)
const activeCategory = category || null
- // Category facets and a per-integration lowercased search index, derived once
- // from the (stable) integration list instead of rebuilt on every keystroke.
- // The index keeps each searchable field as its own entry so matching stays
- // identical to a per-field `includes` (no cross-field boundary matches).
- const { availableCategories, searchIndex } = useMemo(() => {
+ /** Category facets, derived once from the (stable) integration list. */
+ const availableCategories = useMemo(() => {
const counts = new Map()
- const searchIndex = new Map()
for (const i of integrations) {
if (i.integrationType) {
counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
}
- searchIndex.set(i.type, [
- i.name.toLowerCase(),
- i.description.toLowerCase(),
- ...i.operations.flatMap((op) => [op.name.toLowerCase(), op.description.toLowerCase()]),
- ...i.triggers.map((t) => t.name.toLowerCase()),
- ])
- }
- return {
- availableCategories: Array.from(counts.entries())
- .sort((a, b) => b[1] - a[1])
- .map(([key]) => key),
- searchIndex,
}
+ return Array.from(counts.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(([key]) => key)
}, [integrations])
const q = query.trim().toLowerCase()
@@ -61,9 +52,9 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
integrations.filter((i) => {
if (activeCategory && i.integrationType !== activeCategory) return false
if (!q) return true
- return searchIndex.get(i.type)?.some((field) => field.includes(q)) ?? false
+ return i.searchFields.some((field) => field.includes(q))
}),
- [integrations, searchIndex, q, activeCategory]
+ [integrations, q, activeCategory]
)
return (
diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts
index d0019e28aaf..d87b685347a 100644
--- a/apps/sim/blocks/registry.ts
+++ b/apps/sim/blocks/registry.ts
@@ -183,6 +183,8 @@ export function getAllBlockMeta(): Record {
export interface ScopedBlockTemplate extends BlockTemplate {
/** Block types (base form) to render alongside the viewing block in the icon cluster. */
otherBlockTypes: readonly string[]
+ /** Whether the viewing block owns this template, vs matching only via `alsoIntegrations`. */
+ isOwner: boolean
}
/**
@@ -208,7 +210,7 @@ export function getTemplatesForBlock(type: string): ScopedBlockTemplate[] {
const alsoBase = stripVersionSuffix(also)
if (alsoBase !== base && !others.includes(alsoBase)) others.push(alsoBase)
}
- collected.push({ ...template, otherBlockTypes: others })
+ collected.push({ ...template, otherBlockTypes: others, isOwner: isOwnerMatch })
}
}
return collected
diff --git a/apps/sim/lib/integrations/index.ts b/apps/sim/lib/integrations/index.ts
index 926f466ff78..023135c6286 100644
--- a/apps/sim/lib/integrations/index.ts
+++ b/apps/sim/lib/integrations/index.ts
@@ -14,7 +14,7 @@
import { stripVersionSuffix } from '@sim/utils/string'
import integrationsJson from '@/lib/integrations/integrations.json'
-import type { Integration } from '@/lib/integrations/types'
+import type { Integration, IntegrationSummary } from '@/lib/integrations/types'
import { getAllBlockMeta } from '@/blocks/registry'
/** All integrations surfaced in the catalog, ordered by `scripts/generate-docs.ts`. */
@@ -67,13 +67,44 @@ export const POPULAR_WORKFLOWS: readonly PopularWorkflow[] = (() => {
return pairs
})()
+/**
+ * Projects a full `Integration` down to the fields the `/integrations`
+ * catalog grid renders and searches by, replacing the full `operations`/
+ * `triggers` arrays with a precomputed, lowercased `searchFields` index
+ * (name, description, every operation's name and description, every
+ * trigger's name) - the exact same fields the original per-field search
+ * over `Integration[]` matched against. See {@link IntegrationSummary} for
+ * why this exists.
+ */
+export function toIntegrationSummary(integration: Integration): IntegrationSummary {
+ const searchFields = [
+ integration.name.toLowerCase(),
+ integration.description.toLowerCase(),
+ ...integration.operations.flatMap((op) => [
+ op.name.toLowerCase(),
+ op.description.toLowerCase(),
+ ]),
+ ...integration.triggers.map((t) => t.name.toLowerCase()),
+ ]
+
+ return {
+ type: integration.type,
+ slug: integration.slug,
+ name: integration.name,
+ description: integration.description,
+ bgColor: integration.bgColor,
+ integrationType: integration.integrationType,
+ searchFields,
+ }
+}
+
export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
export {
type OAuthServiceMatch,
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
} from '@/lib/integrations/oauth-service'
-export type { AuthType, FAQItem, Integration } from '@/lib/integrations/types'
+export type { AuthType, FAQItem, Integration, IntegrationSummary } from '@/lib/integrations/types'
export { getAllBlockMeta, getBlockMeta, getTemplatesForBlock } from '@/blocks/registry'
export type { BlockMeta, BlockTemplate } from '@/blocks/types'
export { formatIntegrationType } from '@/blocks/types'
diff --git a/apps/sim/lib/integrations/types.ts b/apps/sim/lib/integrations/types.ts
index f3da5aa719a..b1df1a9d59b 100644
--- a/apps/sim/lib/integrations/types.ts
+++ b/apps/sim/lib/integrations/types.ts
@@ -66,3 +66,28 @@ export interface Integration {
/** Hand-authored landing content baked in at generation time (see `landing-content.ts`). */
landingContent?: IntegrationLandingContent
}
+
+/**
+ * The fields the `/integrations` catalog grid actually renders and searches
+ * by, plus a precomputed, lowercased `searchFields` index (name, description,
+ * every operation's name and description, every trigger's name) in place of
+ * the full `operations`/`triggers` arrays. Shipping the full `Integration[]`
+ * to that page's client component embeds every integration's complete field
+ * set - including data the grid never renders (`tags`, `docsUrl`,
+ * `landingContent`, ...) - in the initial HTML/RSC payload.
+ *
+ * `searchFields` stays an array, one entry per source field, rather than a
+ * single joined string: matching must still require the query to fall
+ * entirely within one field (as the original per-field `Integration[]`
+ * search did), not span a field boundary a single concatenated string would
+ * silently allow.
+ */
+export interface IntegrationSummary {
+ type: Integration['type']
+ slug: string
+ name: Integration['name']
+ description: Integration['description']
+ bgColor: Integration['bgColor']
+ integrationType: Integration['integrationType']
+ searchFields: readonly string[]
+}