diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
index 34afe44c09b..ff909ee47fa 100644
--- a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
+++ b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
@@ -19,6 +19,9 @@ import {
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
+ getModelPageDescription,
+ getModelPageName,
+ getModelPageTitle,
getPricingBounds,
getProviderBySlug,
getRelatedModels,
@@ -40,14 +43,6 @@ export async function generateStaticParams() {
)
}
-/**
- * Model name qualified by its reseller ("Claude Opus 4.7 on AWS Bedrock"), so a reseller's page is
- * distinguishable from the first-party page for the same model in titles and the H1.
- */
-function hostedModelName(provider: { name: string; isReseller: boolean }, displayName: string) {
- return provider.isReseller ? `${displayName} on ${provider.name}` : displayName
-}
-
export async function generateMetadata({
params,
}: {
@@ -61,11 +56,12 @@ export async function generateMetadata({
return {}
}
- const pageName = hostedModelName(provider, model.displayName)
+ const title = getModelPageTitle(provider, model)
+ const description = getModelPageDescription(provider, model)
return {
- title: `${pageName} Pricing, Context Window, and Features`,
- description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`,
+ title,
+ description,
keywords: [
model.displayName,
`${model.displayName} pricing`,
@@ -78,14 +74,14 @@ export async function generateMetadata({
// 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: `${pageName} Pricing, Context Window, and Features | Sim`,
- description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
+ title: `${title} | Sim`,
+ description,
url: `${baseUrl}${model.href}`,
type: 'website',
},
twitter: {
card: 'summary_large_image',
- title: `${pageName} | Sim`,
+ title: `${title} | Sim`,
description: model.summary,
},
alternates: {
@@ -184,7 +180,7 @@ export default async function ModelPage({
id='model-heading'
className='text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]'
>
- {hostedModelName(provider, model.displayName)}
+ {getModelPageName(provider, model)}
diff --git a/apps/sim/app/(landing)/models/comparisons.ts b/apps/sim/app/(landing)/models/comparisons.ts
index 3dea1b111b3..2086dfa7bc5 100644
--- a/apps/sim/app/(landing)/models/comparisons.ts
+++ b/apps/sim/app/(landing)/models/comparisons.ts
@@ -3,6 +3,7 @@ import {
type CatalogFaq,
type CatalogModel,
type CatalogProvider,
+ fitWithinLength,
formatPrice,
formatTokenCount,
getEffectiveMaxOutputTokens,
@@ -10,6 +11,7 @@ import {
getProviderBySlug,
hasPublishedPricing,
isPromptModel,
+ MAX_PAGE_TITLE_LENGTH,
} from '@/app/(landing)/models/utils'
import { resolveModelTokenPricing } from '@/providers/pricing'
@@ -151,27 +153,23 @@ function buildComparisons(): ModelComparison[] {
/** Every curated comparison, in {@link MODEL_COMPARISON_PAIRS} order. */
export const MODEL_COMPARISONS: ModelComparison[] = buildComparisons()
-/** Longest page title that keeps the rendered `
` (with the " | Sim" template) at 60 chars. */
-const MAX_COMPARISON_TITLE_LENGTH = 54
-
/** Page title for a comparison, as specific as the 60-character `` budget allows. */
export function getComparisonPageTitle(comparison: ModelComparison): string {
- const candidates = [
- `${comparison.title}: API Pricing & Context Window`,
- `${comparison.title}: API Pricing & Specs`,
- `${comparison.title} API Pricing`,
- ]
- return (
- candidates.find((candidate) => candidate.length <= MAX_COMPARISON_TITLE_LENGTH) ??
- comparison.title
+ return fitWithinLength(
+ [
+ `${comparison.title}: API Pricing & Context Window`,
+ `${comparison.title}: API Pricing & Specs`,
+ `${comparison.title} API Pricing`,
+ comparison.title,
+ ],
+ MAX_PAGE_TITLE_LENGTH
)
}
/** Meta description for a comparison page; long model names drop the trailing Sim sentence. */
export function getComparisonPageDescription(comparison: ModelComparison): string {
const lead = `${comparison.title}: API pricing per 1M tokens, context window, max output, and capabilities side by side.`
- const withSim = `${lead} Use either in Sim's AI agents.`
- return withSim.length <= 155 ? withSim : lead
+ return fitWithinLength([`${lead} Use either in Sim's AI agents.`, lead], 155)
}
/** The curated comparison at `/models/compare/{slug}`; `null` for a reversed or unknown pair. */
diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts
index e37fdf43c9a..169eafc2c51 100644
--- a/apps/sim/app/(landing)/models/utils.ts
+++ b/apps/sim/app/(landing)/models/utils.ts
@@ -297,17 +297,19 @@ function tokenizeModelName(value: string): string[] {
function mergeVersionTokens(tokens: string[]): string[] {
const merged: string[] = []
- for (let index = 0; index < tokens.length; index += 1) {
- const current = tokens[index]
- const next = tokens[index + 1]
-
- if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) {
- merged.push(`${current}.${next}`)
- index += 1
+ for (const token of tokens) {
+ const previous = merged[merged.length - 1]
+
+ if (
+ previous !== undefined &&
+ /^[a-z]*\d{1,2}(\.\d{1,2})*$/i.test(previous) &&
+ /^\d{1,2}$/.test(token)
+ ) {
+ merged[merged.length - 1] = `${previous}.${token}`
continue
}
- merged.push(current)
+ merged.push(token)
}
return merged
@@ -340,17 +342,21 @@ function formatModelToken(token: string): string {
return token.toUpperCase()
}
- if (/^\d+\.\d+$/.test(token)) {
+ if (/^\d+(\.\d+)+$/.test(token)) {
return token
}
- if (/^[a-z]{3,}\d+$/i.test(token)) {
- const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? []
+ if (/^[a-z]{3,}\d+(\.\d+)*$/i.test(token)) {
+ const [, prefix, version] = token.match(/^([a-z]{3,})(\d+(?:\.\d+)*)$/i) ?? []
if (prefix && version) {
return `${formatModelToken(prefix)} ${version}`
}
}
+ if (/^[a-z]\d+(\.\d+)+$/i.test(token)) {
+ return token.toUpperCase()
+ }
+
if (/^[a-z]\d+[a-z]$/i.test(token)) {
return token.toUpperCase()
}
@@ -640,6 +646,12 @@ export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.fil
export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
(provider) => provider.models
)
+/** Display names more than one first-party provider lists, so their pages name the provider too. */
+const SHARED_FIRST_PARTY_NAMES = new Set(
+ ALL_CATALOG_MODELS.map((model) => model.displayName).filter(
+ (name, index, names) => names.indexOf(name) !== index
+ )
+)
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
@@ -923,6 +935,55 @@ export function getProviderPageDescription(provider: CatalogProvider): string {
: `Context windows and capabilities for ${models}. Use any of them in Sim's AI agents.`
}
+/** Longest page title that keeps the rendered `` (with the " | Sim" template) at 60 chars. */
+export const MAX_PAGE_TITLE_LENGTH = 54
+
+/** The first candidate within `maxLength` characters, else the last (shortest) candidate. */
+export function fitWithinLength(candidates: readonly [string, ...string[]], maxLength: number) {
+ return (
+ candidates.find((candidate) => candidate.length <= maxLength) ??
+ candidates[candidates.length - 1]
+ )
+}
+
+/**
+ * Model name qualified by its host ("Claude Opus 4.7 on AWS Bedrock") on a reseller's page or when
+ * two first-party providers list the same name, so every model page has a distinct title and H1.
+ */
+export function getModelPageName(provider: CatalogProvider, model: CatalogModel): string {
+ return provider.isReseller || SHARED_FIRST_PARTY_NAMES.has(model.displayName)
+ ? `${model.displayName} on ${provider.name}`
+ : model.displayName
+}
+
+/**
+ * Model page title, as specific as the 60-character `` budget allows. A model without
+ * published token prices is never titled for pricing.
+ */
+export function getModelPageTitle(provider: CatalogProvider, model: CatalogModel): string {
+ const name = getModelPageName(provider, model)
+ return hasPublishedPricing(model.pricing)
+ ? fitWithinLength(
+ [
+ `${name} API Pricing & Context Window`,
+ `${name} API Pricing & Specs`,
+ `${name} API Pricing`,
+ `${name} Pricing`,
+ name,
+ ],
+ MAX_PAGE_TITLE_LENGTH
+ )
+ : fitWithinLength([`${name} Context Window & Features`, name], MAX_PAGE_TITLE_LENGTH)
+}
+
+/** Model meta description; long model names drop the trailing Sim sentence. */
+export function getModelPageDescription(provider: CatalogProvider, model: CatalogModel): string {
+ const lead = hasPublishedPricing(model.pricing)
+ ? `${model.displayName} by ${provider.name}: API pricing per 1M tokens, cached input cost, context window, and capabilities.`
+ : `${model.displayName} by ${provider.name}: context window, max output, and capabilities.`
+ return fitWithinLength([`${lead} Use it in Sim's AI agents.`, lead], 155)
+}
+
/** Most recent `pricing.updatedAt` across a non-empty list of models. */
export function getLatestModelUpdate(models: CatalogModel[]): Date {
return new Date(Math.max(...models.map((model) => new Date(model.pricing.updatedAt).getTime())))
diff --git a/apps/sim/content/library/best-zapier-alternatives/index.mdx b/apps/sim/content/library/best-zapier-alternatives/index.mdx
index 88a7fc04b42..304c2b1d262 100644
--- a/apps/sim/content/library/best-zapier-alternatives/index.mdx
+++ b/apps/sim/content/library/best-zapier-alternatives/index.mdx
@@ -1,6 +1,6 @@
---
slug: best-zapier-alternatives
-title: '7 Best Zapier Alternatives in 2026, Compared'
+title: '15 Best Zapier Alternatives in 2026, Compared'
description: 'Compare 15 leading Zapier alternatives in 2026 on current pricing, AI agents, self-hosting, small-team fit, Microsoft environments, and enterprise governance.'
seoTitle: 'Best Zapier Alternatives 2026: 15 Tools Compared'
seoDescription: 'Compare 15 Zapier alternatives on pricing, free plans, AI agents, open source, self-hosting, no-code use, and enterprise governance.'
diff --git a/apps/sim/public/library/best-zapier-alternatives/cover.jpg b/apps/sim/public/library/best-zapier-alternatives/cover.jpg
index 6ac80c8b8fb..f07435d2649 100644
Binary files a/apps/sim/public/library/best-zapier-alternatives/cover.jpg and b/apps/sim/public/library/best-zapier-alternatives/cover.jpg differ
diff --git a/apps/sim/scripts/test-landing-seo-e2e.ts b/apps/sim/scripts/test-landing-seo-e2e.ts
index 22f71fab558..32a05a06ddd 100644
--- a/apps/sim/scripts/test-landing-seo-e2e.ts
+++ b/apps/sim/scripts/test-landing-seo-e2e.ts
@@ -19,6 +19,7 @@ import {
vellumProfile,
} from '@/lib/compare/data'
import { ContentFrontmatterSchema } from '@/lib/content/schema'
+import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { INTEGRATION_SEO } from '@/app/(landing)/integrations/data/seo-content'
/**
@@ -243,26 +244,31 @@ async function run() {
)
await check(
- 'model pages carry distinct search titles within 60 characters and self-canonicalize',
+ 'every model page carries a distinct search title within 60 characters and self-canonicalizes',
async () => {
- const comparePaths = sitemapPaths.filter((path) => path.startsWith('/models/compare/'))
+ const paths = sitemapPaths.filter((path) => path === '/models' || path.startsWith('/models/'))
+ const comparePaths = paths.filter((path) => path.startsWith('/models/compare/'))
assert(
comparePaths.length >= 5,
`The sitemap lists only ${comparePaths.length} compare pages`
)
- const paths = [
+ for (const required of [
'/models',
'/models/anthropic',
'/models/context-windows',
'/models/pricing-calculator',
'/models/token-counter',
- ...comparePaths,
- ]
+ ]) {
+ assert(paths.includes(required), `The sitemap omits ${required}`)
+ }
+ const pages = await mapWithConcurrency(paths, 8, async (path) => ({
+ path,
+ ...(await get(path)),
+ }))
const pathByTitle = new Map()
let anthropicTitle = ''
const failures: string[] = []
- for (const path of paths) {
- const { status, body } = await get(path)
+ for (const { path, status, body } of pages) {
const title = pageTitle(body)
const canonical = canonicalPath(body)
const duplicateOf = pathByTitle.get(title)