Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
getModelPageDescription,
getModelPageName,
getModelPageTitle,
getPricingBounds,
getProviderBySlug,
getRelatedModels,
Expand All @@ -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,
}: {
Expand All @@ -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`,
Expand All @@ -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: {
Expand Down Expand Up @@ -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)}
</h1>
</div>
</div>
Expand Down
24 changes: 11 additions & 13 deletions apps/sim/app/(landing)/models/comparisons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
type CatalogFaq,
type CatalogModel,
type CatalogProvider,
fitWithinLength,
formatPrice,
formatTokenCount,
getEffectiveMaxOutputTokens,
getLatestModelUpdate,
getProviderBySlug,
hasPublishedPricing,
isPromptModel,
MAX_PAGE_TITLE_LENGTH,
} from '@/app/(landing)/models/utils'
import { resolveModelTokenPricing } from '@/providers/pricing'

Expand Down Expand Up @@ -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 `<title>` (with the " | Sim" template) at 60 chars. */
const MAX_COMPARISON_TITLE_LENGTH = 54

/** Page title for a comparison, as specific as the 60-character `<title>` 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. */
Expand Down
83 changes: 72 additions & 11 deletions apps/sim/app/(landing)/models/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 `<title>` (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 `<title>` 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())))
Expand Down
Original file line number Diff line number Diff line change
@@ -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.'
Expand Down
Binary file modified apps/sim/public/library/best-zapier-alternatives/cover.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 13 additions & 7 deletions apps/sim/scripts/test-landing-seo-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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<string, string>()
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)
Expand Down
Loading