improvement(integrations): overhaul landing FAQs for SEO/GEO and fix dynamic OG images (#4985)

* improvement(integrations): overhaul landing FAQs for SEO/GEO and fix dynamic OG images

* improvement(integrations): trim comments and fold catalog updatedAt into integrations.json

* fix(integrations): correct FAQ copy for zero-capability and single-tool integrations
This commit is contained in:
Waleed
2026-06-11 17:54:58 -07:00
committed by GitHub
parent 9ab64e55e3
commit 977467970c
17 changed files with 17098 additions and 16908 deletions
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import { useId, useState } from 'react'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -14,7 +14,13 @@ interface LandingFAQProps {
faqs: LandingFAQItem[]
}
/**
* Accordion FAQ for landing pages. Answers stay mounted (collapsed via
* animated height) so non-JS crawlers see the full Q&A text and FAQPage
* JSON-LD always matches visible content.
*/
export function LandingFAQ({ faqs }: LandingFAQProps) {
const baseId = useId()
const [openIndex, setOpenIndex] = useState<number | null>(0)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
@@ -23,8 +29,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
<div>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
const isHovered = hoveredIndex === index
const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
const panelId = `${baseId}-faq-panel-${index}`
return (
<div key={question}>
@@ -34,50 +40,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
index === 0 || !showDivider ? 'invisible' : 'visible'
)}
/>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
aria-expanded={isOpen}
>
<span
className={cn(
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
)}
<h3>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
className='-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
aria-expanded={isOpen}
aria-controls={panelId}
>
{question}
</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<m.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
<span
className={cn(
'text-[15px] leading-snug tracking-[-0.02em] transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
)}
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
{answer}
</p>
</div>
</m.div>
)}
</AnimatePresence>
{question}
</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
</h3>
<m.div
id={panelId}
initial={false}
animate={{ height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
className='overflow-hidden'
aria-hidden={!isOpen}
>
<div className='pt-2 pb-4'>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[1.75]'>
{answer}
</p>
</div>
</m.div>
</div>
)
})}
@@ -0,0 +1,48 @@
import { notFound } from 'next/navigation'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { AuthType, Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
/** Raw catalog JSON, not the barrel — keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const bySlug = new Map(integrations.map((i) => [i.slug, i]))
const AUTH_LABEL: Record<AuthType, string> = {
oauth: 'One-click OAuth',
'api-key': 'API key auth',
none: 'No auth required',
}
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const integration = bySlug.get(slug)
if (!integration) {
notFound()
}
const pills = [
integration.operationCount > 0
? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}`
: null,
integration.triggerCount > 0
? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}`
: null,
AUTH_LABEL[integration.authType],
'Free to start',
].filter((pill): pill is string => pill !== null)
return createLandingOgImage({
eyebrow: 'Sim integration',
title: `${integration.name} Integration`,
subtitle: integration.description,
pills,
domainLabel: `sim.ai/integrations/${slug}`,
})
}
@@ -1,3 +1,4 @@
import { truncate } from '@sim/utils/string'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
@@ -7,7 +8,9 @@ import {
type AuthType,
blockTypeToIconMap,
type FAQItem,
formatIntegrationType,
INTEGRATIONS,
INTEGRATIONS_UPDATED_AT,
type Integration,
} from '@/lib/integrations'
import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button'
@@ -32,6 +35,7 @@ export const dynamicParams = false
* Scoring (additive):
* +3 per shared operation name — strongest signal (same capability)
* +2 per shared operation word — weaker signal (e.g. both have "create" ops)
* +2 same integration category — topical relevance (both CRMs, both devops)
* +1 same auth type — comparable setup experience
*
* Every integration gets a score, so the sidebar always has suggestions.
@@ -41,6 +45,7 @@ function getRelatedSlugs(
slug: string,
operations: Integration['operations'],
authType: AuthType,
integrationType: Integration['integrationType'],
limit = 6
): string[] {
const currentOpNames = new Set(operations.map((o) => o.name.toLowerCase()))
@@ -65,20 +70,28 @@ function getRelatedSlugs(
.split(/\s+/)
.some((w) => w.length > 3 && currentOpWords.has(w))
).length
const sameCategory = i.integrationType === integrationType ? 2 : 0
const sameAuth = i.authType === authType ? 1 : 0
return { slug: i.slug, score: sharedNames * 3 + sharedWords * 2 + sameAuth }
return { slug: i.slug, score: sharedNames * 3 + sharedWords * 2 + sameCategory + sameAuth }
})
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
.slice(0, limit)
.map(({ slug: s }) => s)
}
const AUTH_STEP: Record<AuthType, string> = {
oauth: 'Authenticate with one-click OAuth — no credentials to copy-paste.',
'api-key': 'Add your API key to authenticate — find it in your account settings.',
none: 'Authenticate your account to connect.',
const AUTH_STEP: Record<AuthType, (name: string) => string> = {
oauth: (name) => `Connect your ${name} account with one-click OAuth — no credentials to copy.`,
'api-key': (name) =>
`Paste your ${name} API key to authenticate — you can find it in your ${name} account settings.`,
none: () => 'No authentication is needed — the block works as soon as you drop it in.',
}
/** Human-readable catalog refresh date for the visible last-updated line. */
const UPDATED_AT_DISPLAY = new Date(`${INTEGRATIONS_UPDATED_AT}T00:00:00Z`).toLocaleDateString(
'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }
)
/**
* Ensures autogenerated prose can be safely composed with a following sentence.
*/
@@ -118,68 +131,110 @@ function mentionifyPromptForNames(prompt: string, names: readonly string[]): str
return prompt.replace(regex, (match) => `@${match}`)
}
/** Lowercases only the first character so acronyms in tool names survive. */
function lowercaseFirst(value: string): string {
return value.charAt(0).toLowerCase() + value.slice(1)
}
/** Joins items into readable prose: "a", "a and b", or "a, b, and c". */
function toProseList(items: string[]): string {
if (items.length <= 1) return items[0] ?? ''
if (items.length === 2) return `${items[0]} and ${items[1]}`
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
}
/** "a" vs "an" for a service name; U-names read as "you", so they take "a". */
function articleFor(name: string): string {
return /^[aeio]/i.test(name) ? 'an' : 'a'
}
/**
* Generates targeted FAQs from integration metadata.
* Questions mirror real search queries to drive FAQPage rich snippets.
* Generates the per-integration FAQ. Answers lead with a direct answer and
* carry integration-specific facts; catalog-generic questions live once on
* the /integrations index FAQ instead of repeating across every page.
*/
function buildFAQs(integration: Integration): FAQItem[] {
function buildFAQs(integration: Integration, relatedNames: string[]): FAQItem[] {
const { name, description, operations, triggers, authType } = integration
const faqDescription = sentenceWithTerminalPunctuation(description)
const topOps = operations.slice(0, 5)
const topOpNames = topOps.map((o) => o.name)
const authStep = AUTH_STEP[authType]
const opCount = operations.length
const triggerCount = triggers.length
const topOpNames = operations.slice(0, 5).map((o) => o.name)
const firstOp = operations[0]
const firstTrigger = triggers[0]
const pairings = relatedNames.slice(0, 2)
const toolsPhrase = `${opCount} ${name} tool${opCount === 1 ? '' : 's'}`
const triggersPhrase = `${triggerCount} real-time trigger${triggerCount === 1 ? '' : 's'}`
const capabilityPhrase = [
opCount > 0 ? toolsPhrase : null,
triggerCount > 0 ? triggersPhrase : null,
]
.filter((part): part is string => part !== null)
.join(' and ')
const triggerNames = triggers.map((t) => t.name)
const triggerListPhrase =
triggerCount > 6
? `${triggerNames.slice(0, 6).join(', ')}, and ${triggerCount - 6} more`
: toProseList(triggerNames)
const firstTriggerWhen = firstTrigger?.description.match(/^trigger workflow (when .+)$/i)?.[1]
const connectFinalStep = firstOp
? `Pick a tool such as "${firstOp.name}", wire up its inputs, and click Run — your agent is live.`
: triggerCount > 0
? `Choose the ${name} event you want to listen for, and your agent runs automatically from then on.`
: `Configure the block's inputs and click Run — your agent is live.`
const faqs: FAQItem[] = [
{
question: `What is Sim's ${name} integration?`,
answer: `Sim's ${name} integration lets you build AI agents that automate tasks in ${name} without writing code. ${faqDescription} You can connect ${name} to hundreds of other services in the same agent — from CRMs and spreadsheets to messaging tools and databases.`,
answer: `Sim's ${name} integration ${capabilityPhrase ? `adds ${capabilityPhrase} to` : `connects ${name} to`} the AI agents you build in Sim's visual workflow builder — no code required. ${faqDescription}${
pairings.length === 2
? ` Teams often pair ${name} with ${pairings[0]} and ${pairings[1]} in the same agent.`
: ''
}`,
},
{
question: `What can I automate with ${name} in Sim?`,
answer:
topOpNames.length > 0
? `With Sim you can: ${topOpNames.join('; ')}${operations.length > 5 ? `; and ${operations.length - 5} more tools` : ''}. Each action runs inside an AI agent block, so you can combine ${name} with LLM reasoning, conditional logic, and data from any other connected service.`
: `Sim lets you automate ${name} by connecting it to an AI agent that can read from it, write to it, and chain it together with other services — all driven by natural-language instructions instead of rigid rules.`,
},
{
question: `How do I connect ${name} to Sim?`,
answer: `Getting started takes under five minutes: (1) Create a free account at sim.ai. (2) Open your workspace and create an agent. (3) Drag a ${name} block onto the workflow builder. (4) ${authStep} (5) Choose the tool you want to use, wire it to the inputs you need, and click Run. Your agent is live.`,
},
{
question: `Can I use ${name} as a tool inside an AI agent in Sim?`,
answer: `Yes — this is one of Sim's core capabilities. Instead of hard-coding when and how ${name} is used, you give an AI agent access to ${name} tools and describe the goal in plain language. The agent decides which tools to call, in what order, and how to handle the results. This means your automation adapts to context rather than breaking when inputs change.`,
},
...(topOpNames.length >= 2
...(opCount > 0
? [
{
question: `How do I ${topOpNames[0].toLowerCase()} with ${name} in Sim?`,
answer: `Add a ${name} block to your agent and select "${topOpNames[0]}" as the tool. Fill in the required fields — you can reference outputs from earlier steps, such as text generated by an AI agent or data fetched from another integration. No code is required.`,
question: `What can I automate with ${name} in Sim?`,
answer: `You can ${toProseList(topOpNames.map(lowercaseFirst))} with ${name} in Sim${
opCount > 5 ? `, plus ${opCount - 5} more ${name} tools listed on this page` : ''
}. ${opCount === 1 ? 'It runs' : 'Each runs'} as a tool inside an AI agent block, so an agent can chain ${name} with ${
pairings.length === 2
? `services like ${pairings[0]} and ${pairings[1]}`
: 'any other connected service'
} and apply LLM reasoning between steps.`,
},
]
: []),
...(triggers.length > 0
{
question: `How do I connect ${name} to Sim?`,
answer: `Connecting ${name} takes about five minutes: (1) Create a free account at sim.ai. (2) Create an agent in your workspace. (3) Drag ${articleFor(name)} ${name} block onto the workflow builder. (4) ${AUTH_STEP[authType](name)} (5) ${connectFinalStep}`,
},
...(firstOp && opCount >= 2
? [
{
question: `How do I ${lowercaseFirst(firstOp.name)} with ${name} in Sim?`,
answer: `Add ${articleFor(name)} ${name} block to your agent and select "${firstOp.name}" as the tool.${
firstOp.description ? ` ${sentenceWithTerminalPunctuation(firstOp.description)}` : ''
} Fill in the required fields — inputs can reference outputs from earlier steps, such as text generated by an AI block or data fetched from another integration. No code is required.`,
},
]
: []),
...(triggerCount > 0
? [
{
question: `How do I trigger a Sim agent from ${name} automatically?`,
answer: `Add a ${name} trigger block to your agent and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly runs your agent — no polling, no delay.`,
answer: `Add ${articleFor(name)} ${name} trigger block to your agent and copy its generated webhook URL into ${name}'s webhook settings. Sim supports ${triggersPhrase} for ${name}: ${triggerListPhrase}. Once configured, every matching ${name} event starts your agent instantly — no polling, no delay.`,
},
{
question: `What data does Sim receive when a ${name} event triggers an agent?`,
answer: `When ${name} fires a webhook, Sim receives the full event payload that ${name} sends — typically the record or object that changed, along with metadata like the event type and timestamp. Inside your agent, every field from that payload is available as a variable you can pass to AI blocks, conditions, or other integrations.`,
answer: `Sim receives the full event payload ${name} sends — typically the record or object that changed, plus metadata like the event type and timestamp.${
firstTriggerWhen
? ` For example, the "${firstTrigger.name}" trigger fires ${sentenceWithTerminalPunctuation(firstTriggerWhen)}`
: ''
} Every field in the payload is available as a variable you can pass to AI blocks, conditions, or other integrations.`,
},
]
: []),
{
question: `What ${name} tools does Sim support?`,
answer:
operations.length > 0
? `Sim supports ${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}: ${operations.map((o) => o.name).join(', ')}.`
: `Sim supports core ${name} tools for reading and writing data, triggering actions, and integrating with your other services. See the full list in the Sim documentation.`,
},
{
question: `Is the ${name} integration free to use?`,
answer: `Yes — Sim's free plan includes access to the ${name} integration and every other integration in the library. No credit card is needed to get started. Visit sim.ai to create your account.`,
},
]
return faqs
@@ -203,7 +258,8 @@ export async function generateMetadata({
.slice(0, 3)
.map((o) => o.name)
.join(', ')
const metaDesc = `Automate ${name} with AI agents in Sim. ${description.slice(0, 100).trimEnd()}. Free to start.`
const categoryLabel = formatIntegrationType(integration.integrationType)
const metaDesc = `Automate ${name} with AI agents in Sim. ${sentenceWithTerminalPunctuation(truncate(description, 100))} Free to start.`
return {
title: `${name} Integration`,
@@ -216,29 +272,25 @@ export async function generateMetadata({
`${name} AI agent`,
`${name} AI automation`,
...(opSample ? [`${name} ${opSample}`] : []),
`${categoryLabel} integration`,
...(integration.tags ?? []).map((tag) => `${name} ${tag.replace(/-/g, ' ')}`),
...(integration.triggerCount > 0 ? [`${name} webhook`, `${name} trigger`] : []),
'AI workspace integrations',
'AI agent integrations',
'AI agent builder',
],
// og:image/twitter:image come from the sibling opengraph-image.tsx —
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: `${name} Integration | Sim AI Workspace`,
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`,
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${sentenceWithTerminalPunctuation(truncate(description, 100))}`,
url: `${baseUrl}/integrations/${slug}`,
type: 'website',
images: [
{
url: `${baseUrl}/opengraph-image.png`,
width: 1200,
height: 630,
alt: `${name} Integration — Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${name} Integration | Sim`,
description: `Automate ${name} with AI agents in Sim. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
images: [{ url: `${baseUrl}/opengraph-image.png`, alt: `${name} Integration — Sim` }],
},
alternates: { canonical: `${baseUrl}/integrations/${slug}` },
}
@@ -255,11 +307,15 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
const landingContent = integration.landingContent
const IconComponent = blockTypeToIconMap[integration.type]
const faqs = buildFAQs(integration)
const relatedSlugs = getRelatedSlugs(slug, operations, authType)
const categoryLabel = formatIntegrationType(integration.integrationType)
const relatedSlugs = getRelatedSlugs(slug, operations, authType, integration.integrationType)
const relatedIntegrations = relatedSlugs
.map((s) => bySlug.get(s))
.filter((i): i is Integration => i !== undefined)
const faqs = buildFAQs(
integration,
relatedIntegrations.map((i) => i.name)
)
const matchingTemplates = getTemplatesForBlock(integration.type)
const breadcrumbJsonLd = {
@@ -284,38 +340,16 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
description,
url: `${baseUrl}/integrations/${slug}`,
applicationCategory: 'BusinessApplication',
applicationSubCategory: categoryLabel,
operatingSystem: 'Web',
featureList: operations.map((o) => o.name),
...(integration.tags?.length
? { keywords: integration.tags.map((tag) => tag.replace(/-/g, ' ')).join(', ') }
: {}),
dateModified: INTEGRATIONS_UPDATED_AT,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
}
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: `How to automate ${name} with Sim`,
description: `Step-by-step guide to connecting ${name} to AI agents in Sim.`,
step: [
{
'@type': 'HowToStep',
position: 1,
name: 'Create a free Sim account',
text: 'Sign up at sim.ai — no credit card required.',
},
{
'@type': 'HowToStep',
position: 2,
name: `Add a ${name} block`,
text: `Open your workspace, drag a ${name} block onto the workflow builder, and authenticate with your ${name} credentials.`,
},
{
'@type': 'HowToStep',
position: 3,
name: 'Configure and run',
text: `Choose the operation you want, connect it to an AI agent, and deploy. Automate anything in ${name} without code.`,
},
],
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
@@ -336,10 +370,6 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareAppJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
@@ -404,10 +434,36 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
</div>
</div>
<p className='mb-8 max-w-[700px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
<p className='mb-3 max-w-[700px] text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em]'>
{description}
</p>
<p className='sr-only'>
{name} is a {categoryLabel} integration for Sim, the AI workspace where teams build and
deploy AI agents. Sim&apos;s {name} integration provides{' '}
{[
operations.length > 0
? `${operations.length} ${name} tool${operations.length === 1 ? '' : 's'}`
: null,
triggers.length > 0
? `${triggers.length} real-time trigger${triggers.length === 1 ? '' : 's'}`
: null,
]
.filter((part): part is string => part !== null)
.join(' and ') || `a ${name} connection`}{' '}
that AI agents can use inside Sim&apos;s visual workflow builder.{' '}
{authType === 'oauth'
? `${name} connects with one-click OAuth.`
: authType === 'api-key'
? `${name} connects with an API key.`
: `${name} requires no authentication.`}{' '}
Free to start at sim.ai.
</p>
<p className='mb-8 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
Last updated <time dateTime={INTEGRATIONS_UPDATED_AT}>{UPDATED_AT_DISPLAY}</time>
</p>
{/* CTAs */}
<div className='flex flex-wrap gap-2'>
<IntegrationCtaButton
@@ -582,13 +638,13 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
},
{
step: '02',
title: `Add a ${name} block`,
title: `Add ${articleFor(name)} ${name} block`,
body:
authType === 'oauth'
? `Open your workspace, drag a ${name} block onto the workflow builder, and connect your account with one-click OAuth.`
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and connect your account with one-click OAuth.`
: authType === 'api-key'
? `Open your workspace, drag a ${name} block onto the workflow builder, and paste in your ${name} API key.`
: `Open your workspace, drag a ${name} block onto the workflow builder, and authenticate your account.`,
? `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder, and paste in your ${name} API key.`
: `Open your workspace, drag ${articleFor(name)} ${name} block onto the workflow builder — no authentication is needed.`,
},
{
step: '03',
@@ -633,8 +689,8 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
</h2>
</div>
<p className='text-[14px] text-[var(--landing-text-body)] leading-[150%] tracking-[0.02em]'>
Connect a {name} webhook to Sim and your agent runs the instant an event happens, no
polling, no delay.
Connect {articleFor(name)} {name} webhook to Sim and your agent runs the instant an
event happens, no polling, no delay.
</p>
</div>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
@@ -0,0 +1,30 @@
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
/** Raw catalog JSON, not the barrel — keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const TOTAL_TOOL_COUNT = integrations.reduce((sum, i) => sum + i.operationCount, 0)
const OAUTH_COUNT = integrations.filter((i) => i.authType === 'oauth').length
const TRIGGER_INTEGRATION_COUNT = integrations.filter((i) => i.triggerCount > 0).length
export default async function Image() {
return createLandingOgImage({
eyebrow: 'Sim integrations directory',
title: 'Integrations',
subtitle: `Connect ${integrations.length} apps and services to AI agents in Sim's workflow builder — visually, conversationally, or with code.`,
pills: [
`${integrations.length} integrations`,
`${TOTAL_TOOL_COUNT}+ tools`,
`${OAUTH_COUNT} OAuth apps`,
`${TRIGGER_INTEGRATION_COUNT} with real-time triggers`,
],
domainLabel: 'sim.ai/integrations',
})
}
@@ -3,16 +3,48 @@ import { Badge } from '@/components/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
import {
blockTypeToIconMap,
type FAQItem,
INTEGRATIONS,
type Integration,
POPULAR_WORKFLOWS,
} from '@/lib/integrations'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
import { IntegrationGrid } from '@/app/(landing)/integrations/components/integration-grid'
import { RequestIntegrationModal } from '@/app/(landing)/integrations/components/request-integration-modal'
const allIntegrations = INTEGRATIONS
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
const TOTAL_TOOL_COUNT = allIntegrations.reduce((sum, i) => sum + i.operationCount, 0)
/**
* Catalog-level FAQ. Questions that read the same for every integration live
* here exactly once instead of repeating across all per-integration pages.
*/
const CATALOG_FAQS: FAQItem[] = [
{
question: 'How do integrations work in Sim?',
answer: `Each integration is a block you drag onto Sim's workflow builder. Together, Sim's ${INTEGRATION_COUNT} integrations expose ${TOTAL_TOOL_COUNT}+ tools that AI agents can call — ${OAUTH_COUNT} connect with one-click OAuth, and the rest use an API key or no authentication at all. Wire blocks together, add an AI agent block for reasoning, and run.`,
},
{
question: 'Are Sim integrations free to use?',
answer: `Yes — Sim's free plan includes every integration in the library, all ${INTEGRATION_COUNT} of them, with no credit card required. Create an account at sim.ai and start building.`,
},
{
question: 'Can an AI agent decide when to use an integration?',
answer: `Yes — this is the core of Sim. You give an agent access to integration tools and describe the goal in plain language; the agent decides which tools to call, in what order, and how to handle the results. Automations adapt to context instead of breaking when inputs change.`,
},
{
question: 'Can external events trigger my agents automatically?',
answer: `Yes — ${TRIGGER_INTEGRATION_COUNT} Sim integrations include real-time webhook triggers. Add a trigger block to your agent, copy its webhook URL into the external service, and every matching event starts your agent instantly — no polling, no delay.`,
},
{
question: 'How many integrations does Sim support?',
answer: `Sim supports ${INTEGRATION_COUNT} integrations across messaging, CRMs, databases, developer tools, AI providers, and more — and the catalog grows continually. If a tool you need is missing, request it below and we'll prioritize it.`,
},
]
/**
* Unique integration names that appear in popular workflow pairs.
@@ -40,27 +72,18 @@ export const metadata: Metadata = {
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
],
// og:image/twitter:image come from the sibling opengraph-image.tsx —
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: 'Integrations | Sim AI Workspace',
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
url: `${baseUrl}/integrations`,
type: 'website',
images: [
{
url: `${baseUrl}/opengraph-image.png`,
width: 1200,
height: 630,
alt: 'Sim Integrations for AI Workflow Automation',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`,
images: [
{ url: `${baseUrl}/opengraph-image.png`, alt: 'Sim Integrations for AI Workflow Automation' },
],
},
alternates: { canonical: `${baseUrl}/integrations` },
}
@@ -101,6 +124,16 @@ export default function IntegrationsPage() {
})),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: CATALOG_FAQS.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
}
return (
<section className='bg-[var(--landing-bg)]'>
<script
@@ -111,6 +144,10 @@ export default function IntegrationsPage() {
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
{/* Hero */}
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
@@ -171,6 +208,21 @@ export default function IntegrationsPage() {
<IntegrationGrid integrations={allIntegrations} />
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* FAQ */}
<section aria-labelledby='integrations-faq-heading' className='px-6 py-10'>
<h2
id='integrations-faq-heading'
className='mb-8 text-[20px] text-white leading-[100%] tracking-[-0.02em]'
>
Frequently asked questions
</h2>
<LandingFAQ faqs={CATALOG_FAQS} />
</section>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
{/* Integration request */}
<div className='flex flex-col items-start gap-3 p-6 sm:flex-row sm:items-center sm:justify-between'>
<div>
@@ -1,13 +1,12 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
getModelBySlug,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
@@ -27,7 +26,7 @@ export default async function Image({
notFound()
}
return createModelsOgImage({
return createLandingOgImage({
eyebrow: `${provider.name} model`,
title: model.displayName,
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
@@ -54,27 +54,18 @@ export async function generateMetadata({
`${provider.name} model pricing`,
...model.capabilityTags,
],
// og:image/twitter:image come from the sibling opengraph-image.tsx —
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: `${model.displayName} Pricing, Context Window, and Features | Sim`,
description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
url: `${baseUrl}${model.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${model.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${model.displayName} on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${model.displayName} | Sim`,
description: model.summary,
images: [
{ url: `${baseUrl}${model.href}/opengraph-image`, alt: `${model.displayName} on Sim` },
],
},
alternates: {
canonical: `${baseUrl}${model.href}`,
@@ -1,5 +1,4 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
@@ -7,8 +6,8 @@ import {
getLargestContextProviderModel,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
@@ -26,7 +25,7 @@ export default async function Image({ params }: { params: Promise<{ provider: st
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return createModelsOgImage({
return createLandingOgImage({
eyebrow: `${provider.name} on Sim`,
title: `${provider.name} models`,
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
@@ -55,30 +55,18 @@ export async function generateMetadata({
`${provider.name} AI models`,
...provider.models.slice(0, 6).map((model) => model.displayName),
],
// og:image/twitter:image come from the sibling opengraph-image.tsx —
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: `${provider.name} Models | Sim`,
description: `Explore ${provider.modelCount} ${provider.name} models with pricing and capability details.`,
url: `${baseUrl}${provider.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${provider.name} Models on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${provider.name} Models | Sim`,
description: providerFaqs[0]?.answer ?? provider.summary,
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
alt: `${provider.name} Models on Sim`,
},
],
},
alternates: {
canonical: `${baseUrl}${provider.href}`,
@@ -1,12 +1,11 @@
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatTokenCount,
MAX_CONTEXT_WINDOW,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
@@ -14,7 +13,7 @@ export const size = {
}
export default async function Image() {
return createModelsOgImage({
return createLandingOgImage({
eyebrow: 'Sim model directory',
title: 'AI Models Directory',
subtitle:
@@ -63,25 +63,18 @@ export const metadata: Metadata = {
'Mistral models',
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
],
// og:image/twitter:image come from the sibling opengraph-image.tsx —
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
openGraph: {
title: 'AI Models Directory | Sim',
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
url: `${baseUrl}/models`,
type: 'website',
images: [
{
url: `${baseUrl}/models/opengraph-image`,
width: 1200,
height: 630,
alt: 'Sim AI Models Directory',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI Models Directory | Sim',
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
images: [{ url: `${baseUrl}/models/opengraph-image`, alt: 'Sim AI Models Directory' }],
},
alternates: {
canonical: `${baseUrl}/models`,
@@ -61,7 +61,7 @@ function SimLogoFull() {
)
}
interface ModelsOgImageProps {
interface LandingOgImageProps {
eyebrow: string
title: string
subtitle: string
@@ -69,13 +69,14 @@ interface ModelsOgImageProps {
domainLabel?: string
}
export async function createModelsOgImage({
/** Shared dynamic OG image for landing catalog pages (models, integrations). */
export async function createLandingOgImage({
eyebrow,
title,
subtitle,
pills = [],
domainLabel = 'sim.ai/models',
}: ModelsOgImageProps) {
domainLabel = 'sim.ai',
}: LandingOgImageProps) {
const text = `${eyebrow}${title}${subtitle}${pills.join('')}${domainLabel}`
const [regularFontData, mediumFontData] = await Promise.all([
loadGoogleFont('Geist', '400', text),
+5 -2
View File
@@ -2,7 +2,7 @@ import type { MetadataRoute } from 'next'
import { COURSES } from '@/lib/academy/content'
import { getAllPostMeta } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import { INTEGRATIONS } from '@/lib/integrations'
import { INTEGRATIONS, INTEGRATIONS_UPDATED_AT } from '@/lib/integrations'
import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
/**
@@ -27,6 +27,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
)
const latestModelDate = modelTimes.length > 0 ? new Date(Math.max(...modelTimes)) : undefined
const integrationsUpdatedAt = new Date(`${INTEGRATIONS_UPDATED_AT}T00:00:00Z`)
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
@@ -45,7 +47,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
},
{
url: `${baseUrl}/integrations`,
lastModified: latestModelDate,
lastModified: integrationsUpdatedAt,
},
{
url: `${baseUrl}/models`,
@@ -89,6 +91,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const integrationPages: MetadataRoute.Sitemap = INTEGRATIONS.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
lastModified: integrationsUpdatedAt,
}))
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) => {
+9 -1
View File
@@ -18,7 +18,15 @@ import type { Integration } from '@/lib/integrations/types'
import { getAllBlockMeta } from '@/blocks/registry'
/** All integrations surfaced in the catalog, ordered by `scripts/generate-docs.ts`. */
export const INTEGRATIONS: readonly Integration[] = integrationsJson as readonly Integration[]
export const INTEGRATIONS: readonly Integration[] =
integrationsJson.integrations as readonly Integration[]
/**
* ISO date of the last real catalog change, stamped by `scripts/generate-docs.ts`.
* Drives sitemap `lastModified`, JSON-LD `dateModified`, and the visible
* last-updated line on integration pages.
*/
export const INTEGRATIONS_UPDATED_AT: string = integrationsJson.updatedAt
/** A curated `from → to` block-pair workflow surfaced on the landing page. */
export interface PopularWorkflow {
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,8 @@ import type { Integration } from '@/lib/integrations/types'
import { OAUTH_PROVIDERS } from '@/lib/oauth'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
const INTEGRATIONS_DATA: readonly Integration[] = integrationsJson as readonly Integration[]
const INTEGRATIONS_DATA: readonly Integration[] =
integrationsJson.integrations as readonly Integration[]
/**
* Shape returned from resolving an integration to its OAuth service entry in
+21 -8
View File
@@ -851,14 +851,27 @@ async function writeIntegrationsJson(iconMapping: Record<string, string>): Promi
// JSON formatter inlines short arrays of primitive strings. Pre-collapse those
// arrays here so the emitted file is already in Biome's canonical shape and
// `bun run check` does not churn it on every commit.
const json = JSON.stringify(integrations, null, 2).replace(
/\[\n(\s+"[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s+\]/g,
(_match, inner) => {
const items = (inner as string).split(',\n').map((s: string) => s.trim())
return `[${items.join(', ')}]`
}
)
fs.writeFileSync(jsonPath, `${json}\n`)
const serialize = (value: unknown) =>
JSON.stringify(value, null, 2).replace(
/\[\n(\s+"[^"\n]*"(?:,\n\s+"[^"\n]*")*)\n\s+\]/g,
(_match, inner) => {
const items = (inner as string).split(',\n').map((s: string) => s.trim())
return `[${items.join(', ')}]`
}
)
// `updatedAt` is re-stamped only when the integrations content actually
// changes, so sitemap/JSON-LD freshness never churns on no-op regens.
const previous = fs.existsSync(jsonPath)
? (JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as { integrations?: unknown })
: null
if (previous?.integrations && serialize(previous.integrations) === serialize(integrations)) {
console.log(`✓ Integration data unchanged: ${integrations.length} integrations → ${jsonPath}`)
return
}
const updatedAt = new Date().toISOString().slice(0, 10)
fs.writeFileSync(jsonPath, `${serialize({ updatedAt, integrations })}\n`)
console.log(`✓ Integration data written: ${integrations.length} integrations → ${jsonPath}`)
} catch (error) {
// Surface taxonomy violations (missing/invalid `integrationType`) loudly —