Skip to content

Commit 48c2d2e

Browse files
committed
improvement(seo): extract shared withFilteredNoindex helper
The `{ ...base, ...(isFiltered && { robots: { index: false, follow: true } }) }` faceted-navigation noindex pattern was duplicated verbatim across five catalog pages (integrations, models, blog, careers, pricing). Extracted to lib/landing/seo.ts alongside buildLandingMetadata, giving the pattern a name and a single point of change. Pure refactor — verified byte-identical rendered output (robots meta + canonical) on baseline and filtered variants of all five pages before and after.
1 parent 8937005 commit 48c2d2e

6 files changed

Lines changed: 110 additions & 88 deletions

File tree

apps/sim/app/(landing)/blog/page.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from 'next/link'
55
import { getAllPostMeta } from '@/lib/blog/registry'
66
import { buildCollectionPageJsonLd } from '@/lib/blog/seo'
77
import { SITE_URL } from '@/lib/core/utils/urls'
8+
import { withFilteredNoindex } from '@/lib/landing/seo'
89
import { Cta } from '@/app/(landing)/components'
910
import { JsonLd } from '@/app/(landing)/components/json-ld'
1011

@@ -35,34 +36,36 @@ export async function generateMetadata({
3536
const canonical = `${SITE_URL}/blog`
3637
const isFiltered = Boolean(tag) || pageNum > 1
3738

38-
return {
39-
title,
40-
description,
41-
alternates: { canonical },
42-
openGraph: {
43-
title: `${title} | Sim`,
39+
return withFilteredNoindex(
40+
{
41+
title,
4442
description,
45-
url: canonical,
46-
siteName: 'Sim',
47-
locale: 'en_US',
48-
type: 'website',
49-
images: [
50-
{
51-
url: `${SITE_URL}/logo/primary/medium.png`,
52-
width: 1200,
53-
height: 630,
54-
alt: 'Sim Blog',
55-
},
56-
],
43+
alternates: { canonical },
44+
openGraph: {
45+
title: `${title} | Sim`,
46+
description,
47+
url: canonical,
48+
siteName: 'Sim',
49+
locale: 'en_US',
50+
type: 'website',
51+
images: [
52+
{
53+
url: `${SITE_URL}/logo/primary/medium.png`,
54+
width: 1200,
55+
height: 630,
56+
alt: 'Sim Blog',
57+
},
58+
],
59+
},
60+
twitter: {
61+
card: 'summary_large_image',
62+
title: `${title} | Sim`,
63+
description,
64+
site: '@simdotai',
65+
},
5766
},
58-
twitter: {
59-
card: 'summary_large_image',
60-
title: `${title} | Sim`,
61-
description,
62-
site: '@simdotai',
63-
},
64-
...(isFiltered && { robots: { index: false, follow: true } }),
65-
}
67+
isFiltered
68+
)
6669
}
6770

6871
export default async function BlogIndex({

apps/sim/app/(landing)/careers/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import type { SearchParams } from 'nuqs/server'
3-
import { buildLandingMetadata } from '@/lib/landing/seo'
3+
import { buildLandingMetadata, withFilteredNoindex } from '@/lib/landing/seo'
44
import Careers from '@/app/(landing)/careers/careers'
55
import { ALL_FILTER_VALUE, careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
66

@@ -26,7 +26,7 @@ export async function generateMetadata({
2626
'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
2727
})
2828

29-
return { ...base, ...(isFiltered && { robots: { index: false, follow: true } }) }
29+
return withFilteredNoindex(base, isFiltered)
3030
}
3131

3232
export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {

apps/sim/app/(landing)/integrations/(shell)/page.tsx

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type Integration,
99
POPULAR_WORKFLOWS,
1010
} from '@/lib/integrations'
11+
import { withFilteredNoindex } from '@/lib/landing/seo'
1112
import { JsonLd } from '@/app/(landing)/components/json-ld'
1213
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
1314
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
@@ -92,32 +93,34 @@ export async function generateMetadata({
9293
const { q, category } = await integrationsSearchParamsCache.parse(searchParams)
9394
const isFiltered = Boolean(q || category)
9495

95-
return {
96-
title: 'Integrations',
97-
description: `Connect ${INTEGRATION_COUNT}+ apps and services in Sim's AI workspace. Build agents that automate real work with ${TOP_NAMES.join(', ')}, and more.`,
98-
keywords: [
99-
'AI workspace integrations',
100-
'AI agent integrations',
101-
'AI agent builder integrations',
102-
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
103-
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
104-
],
105-
// og:image/twitter:image come from the sibling opengraph-image.tsx -
106-
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
107-
openGraph: {
108-
title: 'Integrations | Sim AI Workspace',
109-
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
110-
url: `${baseUrl}/integrations`,
111-
type: 'website',
112-
},
113-
twitter: {
114-
card: 'summary_large_image',
115-
title: 'Integrations | Sim',
116-
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`,
96+
return withFilteredNoindex(
97+
{
98+
title: 'Integrations',
99+
description: `Connect ${INTEGRATION_COUNT}+ apps and services in Sim's AI workspace. Build agents that automate real work with ${TOP_NAMES.join(', ')}, and more.`,
100+
keywords: [
101+
'AI workspace integrations',
102+
'AI agent integrations',
103+
'AI agent builder integrations',
104+
...TOP_NAMES.flatMap((n) => [`${n} integration`, `${n} automation`]),
105+
...allIntegrations.slice(0, 20).map((i) => `${i.name} automation`),
106+
],
107+
// og:image/twitter:image come from the sibling opengraph-image.tsx -
108+
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
109+
openGraph: {
110+
title: 'Integrations | Sim AI Workspace',
111+
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace. Build agents that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
112+
url: `${baseUrl}/integrations`,
113+
type: 'website',
114+
},
115+
twitter: {
116+
card: 'summary_large_image',
117+
title: 'Integrations | Sim',
118+
description: `Connect ${INTEGRATION_COUNT}+ apps in Sim's AI workspace.`,
119+
},
120+
alternates: { canonical: `${baseUrl}/integrations` },
117121
},
118-
alternates: { canonical: `${baseUrl}/integrations` },
119-
...(isFiltered && { robots: { index: false, follow: true } }),
120-
}
122+
isFiltered
123+
)
121124
}
122125

123126
export default async function IntegrationsPage({

apps/sim/app/(landing)/models/(shell)/page.tsx

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
22
import type { SearchParams } from 'nuqs/server'
33
import { SITE_URL } from '@/lib/core/utils/urls'
4+
import { withFilteredNoindex } from '@/lib/landing/seo'
45
import { JsonLd } from '@/app/(landing)/components/json-ld'
56
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
67
import { ModelComparisonCharts } from '@/app/(landing)/models/components/model-comparison-charts'
@@ -74,40 +75,42 @@ export async function generateMetadata({
7475
const { q, provider } = await modelsSearchParamsCache.parse(searchParams)
7576
const isFiltered = Boolean(q || provider)
7677

77-
return {
78-
title: 'AI Models Directory',
79-
description: `Compare ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers in Sim's AI workspace. Compare pricing, context windows, and capabilities for your agents.`,
80-
keywords: [
81-
'AI models directory',
82-
'AI model comparison',
83-
'LLM model list',
84-
'model pricing',
85-
'context window comparison',
86-
'OpenAI models',
87-
'Anthropic models',
88-
'Google Gemini models',
89-
'xAI Grok models',
90-
'Mistral models',
91-
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
92-
],
93-
// og:image/twitter:image come from the sibling opengraph-image.tsx -
94-
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
95-
openGraph: {
96-
title: 'AI Models Directory | Sim',
97-
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
98-
url: `${baseUrl}/models`,
99-
type: 'website',
100-
},
101-
twitter: {
102-
card: 'summary_large_image',
103-
title: 'AI Models Directory | Sim',
104-
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
105-
},
106-
alternates: {
107-
canonical: `${baseUrl}/models`,
78+
return withFilteredNoindex(
79+
{
80+
title: 'AI Models Directory',
81+
description: `Compare ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers in Sim's AI workspace. Compare pricing, context windows, and capabilities for your agents.`,
82+
keywords: [
83+
'AI models directory',
84+
'AI model comparison',
85+
'LLM model list',
86+
'model pricing',
87+
'context window comparison',
88+
'OpenAI models',
89+
'Anthropic models',
90+
'Google Gemini models',
91+
'xAI Grok models',
92+
'Mistral models',
93+
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
94+
],
95+
// og:image/twitter:image come from the sibling opengraph-image.tsx -
96+
// Next serves it at a hash-suffixed URL, so hardcoding it here 404s.
97+
openGraph: {
98+
title: 'AI Models Directory | Sim',
99+
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
100+
url: `${baseUrl}/models`,
101+
type: 'website',
102+
},
103+
twitter: {
104+
card: 'summary_large_image',
105+
title: 'AI Models Directory | Sim',
106+
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
107+
},
108+
alternates: {
109+
canonical: `${baseUrl}/models`,
110+
},
108111
},
109-
...(isFiltered && { robots: { index: false, follow: true } }),
110-
}
112+
isFiltered
113+
)
111114
}
112115

113116
export default async function ModelsPage({

apps/sim/app/(landing)/pricing/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Metadata } from 'next'
22
import type { SearchParams } from 'nuqs/server'
3-
import { buildLandingMetadata } from '@/lib/landing/seo'
3+
import { buildLandingMetadata, withFilteredNoindex } from '@/lib/landing/seo'
44
import Pricing from '@/app/(landing)/pricing/pricing'
55
import { pricingSearchParamsCache } from '@/app/(landing)/pricing/search-params'
66

@@ -29,7 +29,7 @@ export async function generateMetadata({
2929
'Sim pricing, AI workspace pricing, AI agent platform pricing, build AI agents, Pro plan, Max plan, Enterprise plan, open-source AI agents, LLM pricing',
3030
})
3131

32-
return { ...base, ...(isFiltered && { robots: { index: false, follow: true } }) }
32+
return withFilteredNoindex(base, isFiltered)
3333
}
3434

3535
export default async function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {

apps/sim/lib/landing/seo.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,16 @@ export function buildLandingMetadata({
8787
category: 'technology',
8888
}
8989
}
90+
91+
/**
92+
* Google's documented pattern for faceted/filtered navigation: keep the single
93+
* unfiltered listing indexable and `noindex` (but still `follow`) any
94+
* filtered or paginated variant, so link equity flows through without asking
95+
* Google to index every query-param permutation. Used by every catalog page
96+
* that serves distinct content per query param (integrations, models, blog,
97+
* careers, pricing) — `metadata.alternates.canonical` on all of them still
98+
* points at the bare URL regardless of `isFiltered`.
99+
*/
100+
export function withFilteredNoindex(metadata: Metadata, isFiltered: boolean): Metadata {
101+
return { ...metadata, ...(isFiltered && { robots: { index: false, follow: true } }) }
102+
}

0 commit comments

Comments
 (0)