Compare commits

...

1 Commits

Author SHA1 Message Date
Emir Karabeg
75b1ed304e improvement(landing, blog): SEO and GEO optimization 2026-04-05 12:35:00 -07:00
15 changed files with 428 additions and 179 deletions

View File

@@ -1,26 +1,39 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, ChevronLeft } from 'lucide-react'
import Link from 'next/link'
export function BackLink() {
const [isHovered, setIsHovered] = useState(false)
return (
<Link
href='/blog'
className='group flex items-center gap-1 text-[var(--landing-text-muted)] text-sm hover:text-[var(--landing-text)]'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='group/link inline-flex items-center gap-1.5 font-season text-[var(--landing-text-muted)] text-sm tracking-[0.02em] hover:text-[var(--landing-text)]'
>
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
{isHovered ? (
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
<svg
className='h-3 w-3 shrink-0'
viewBox='0 0 10 10'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<line
x1='1'
y1='5'
x2='10'
y2='5'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
className='origin-right scale-x-0 transition-transform duration-200 ease-out [transform-box:fill-box] group-hover/link:scale-x-100'
/>
<path
d='M6.5 2L3.5 5L6.5 8'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
fill='none'
className='group-hover/link:-translate-x-[30%] transition-transform duration-200 ease-out'
/>
</svg>
Back to Blog
</Link>
)

View File

@@ -2,58 +2,51 @@ import { Skeleton } from '@/components/emcn'
export default function BlogPostLoading() {
return (
<article className='w-full'>
{/* Header area */}
<div className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
{/* Back link */}
<article className='w-full bg-[var(--landing-bg)]'>
<div className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<div className='mb-6'>
<Skeleton className='h-[16px] w-[60px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
{/* Image + title row */}
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
{/* Image */}
<div className='w-full flex-shrink-0 md:w-[450px]'>
<Skeleton className='aspect-[450/360] w-full rounded-lg bg-[var(--landing-bg-elevated)]' />
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--landing-bg-elevated)]' />
</div>
{/* Title + author */}
<div className='flex flex-1 flex-col justify-between'>
<div>
<Skeleton className='h-[48px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-2 h-[48px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
<div className='mt-4 flex items-center justify-between'>
<div className='mt-6 flex items-center gap-6'>
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<div className='flex items-center gap-2'>
<Skeleton className='h-[24px] w-[24px] rounded-full bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[20px] w-[20px] rounded-full bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
<Skeleton className='h-[32px] w-[32px] rounded-[6px] bg-[var(--landing-bg-elevated)]' />
</div>
</div>
</div>
{/* Divider */}
<Skeleton className='mt-8 h-[1px] w-full bg-[var(--landing-bg-elevated)] sm:mt-12' />
{/* Date + description */}
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
<Skeleton className='h-[16px] w-[120px] flex-shrink-0 rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<div className='flex-1 space-y-2'>
<Skeleton className='h-[20px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[20px] w-[70%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
<div className='mx-auto max-w-[900px] px-6 py-16'>
<div className='space-y-4'>
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
</div>
</div>
{/* Article body */}
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12'>
<div className='space-y-4'>
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--landing-bg-elevated)]' />
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
</article>
)
}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { BackLink } from '@/app/(landing)/blog/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
@@ -30,27 +30,27 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const { slug } = await params
const post = await getPostBySlug(slug)
const Article = post.Content
const jsonLd = buildArticleJsonLd(post)
const breadcrumbLd = buildBreadcrumbJsonLd(post)
const graphJsonLd = buildPostGraphJsonLd(post)
const related = await getRelatedPosts(slug, 3)
return (
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
<article
className='w-full bg-[var(--landing-bg)]'
itemScope
itemType='https://schema.org/TechArticle'
>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(graphJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<header className='px-5 pt-[60px] lg:px-16 lg:pt-[100px]'>
<div className='mb-6'>
<BackLink />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<div className='relative w-full overflow-hidden rounded-lg'>
<div className='relative w-full overflow-hidden rounded-[5px]'>
<Image
src={post.ogImage}
alt={post.title}
@@ -65,18 +65,35 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</div>
</div>
<div className='flex flex-1 flex-col justify-between'>
<h1
className='text-balance font-[500] text-[36px] text-[var(--landing-text)] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
itemProp='headline'
>
{post.title}
</h1>
<div className='mt-4 flex items-center justify-between'>
<div>
<h1
className='text-balance font-[430] font-season text-[28px] text-white leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
itemProp='headline'
>
{post.title}
</h1>
<p className='mt-4 font-[430] font-season text-[var(--landing-text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
{post.description}
</p>
</div>
<div className='mt-6 flex items-center gap-6'>
<time
className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<Avatar className='size-5'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
@@ -85,7 +102,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[var(--landing-text-muted)] text-sm leading-[1.5] hover:text-[var(--landing-text)] sm:text-md'
className='font-martian-mono text-[var(--landing-text-muted)] text-xs uppercase tracking-[0.1em] hover:text-white'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
@@ -95,78 +112,72 @@ export default async function Page({ params }: { params: Promise<{ slug: string
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
<div className='ml-auto'>
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
</div>
</div>
</div>
</div>
<hr className='mt-8 border-[var(--landing-bg-elevated)] border-t sm:mt-12' />
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
<div className='flex flex-shrink-0 items-center gap-4'>
<time
className='block text-[var(--landing-text-muted)] text-sm leading-[1.5] sm:text-md'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
</div>
<div className='flex-1'>
<p className='m-0 block translate-y-[-4px] font-[400] text-[var(--landing-text-muted)] text-lg leading-[1.5] sm:text-[20px] md:text-[26px]'>
{post.description}
</p>
</div>
</div>
</header>
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[var(--landing-border-strong)] prose-hr:border-[var(--landing-bg-elevated)] prose-a:text-[var(--landing-text)] prose-blockquote:text-[var(--landing-text-muted)] prose-code:text-[var(--landing-text)] prose-headings:text-[var(--landing-text)] prose-li:text-[var(--landing-text-muted)] prose-p:text-[var(--landing-text-muted)] prose-strong:text-[var(--landing-text)]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
<h2 className='mb-4 font-[500] text-[24px] text-[var(--landing-text)]'>Related posts</h2>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
{related.map((p) => (
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)]'>
<Image
src={p.ogImage}
alt={p.title}
width={600}
height={315}
className='h-[160px] w-full object-cover'
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
loading='lazy'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--landing-text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='font-[500] text-[var(--landing-text)] text-sm leading-tight'>
{p.title}
</div>
</div>
</div>
</Link>
))}
<div className='mt-8 h-px w-full bg-[var(--landing-bg-elevated)]' />
<div className='mx-5 border-[var(--landing-bg-elevated)] border-x lg:mx-16'>
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
<div className='prose prose-lg prose-invert max-w-none prose-blockquote:border-[var(--landing-border-strong)] prose-hr:border-[var(--landing-bg-elevated)] prose-headings:font-[430] prose-headings:font-season prose-a:text-white prose-blockquote:text-[var(--landing-text-muted)] prose-code:text-white prose-headings:text-white prose-li:text-[var(--landing-text-body)] prose-p:text-[var(--landing-text-body)] prose-strong:text-white prose-headings:tracking-[-0.02em]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
)}
{related.length > 0 && (
<>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
<nav aria-label='Related posts' className='flex'>
{related.map((p) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:border-l md:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='font-[430] font-season text-lg text-white leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--landing-text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
</>
)}
</div>
<div className='-mt-px h-px w-full bg-[var(--landing-bg-elevated)]' />
<meta itemProp='publisher' content='Sim' />
<meta itemProp='inLanguage' content='en-US' />
<meta itemProp='keywords' content={post.tags.join(', ')} />
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
</article>
)
}

View File

@@ -13,7 +13,29 @@ export async function generateMetadata({
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
return { title: author?.name ?? 'Author' }
const name = author?.name ?? 'Author'
return {
title: `${name} — Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
alternates: { canonical: `https://sim.ai/blog/authors/${id}` },
openGraph: {
title: `${name} — Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
url: `https://sim.ai/blog/authors/${id}`,
siteName: 'Sim',
type: 'profile',
...(author?.avatarUrl
? { images: [{ url: author.avatarUrl, width: 400, height: 400, alt: name }] }
: {}),
},
twitter: {
card: 'summary',
title: `${name} — Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
site: '@simdotai',
...(author?.xHandle ? { creator: `@${author.xHandle}` } : {}),
},
}
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
@@ -27,19 +49,41 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
</main>
)
}
const personJsonLd = {
const graphJsonLd = {
'@context': 'https://schema.org',
'@type': 'Person',
name: author.name,
url: `https://sim.ai/blog/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
'@graph': [
{
'@type': 'Person',
name: author.name,
url: `https://sim.ai/blog/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
worksFor: {
'@type': 'Organization',
name: 'Sim',
url: 'https://sim.ai',
},
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://sim.ai/blog' },
{
'@type': 'ListItem',
position: 3,
name: author.name,
item: `https://sim.ai/blog/authors/${author.id}`,
},
],
},
],
}
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(graphJsonLd) }}
/>
<div className='mb-6 flex items-center gap-3'>
{author.avatarUrl ? (

View File

@@ -9,8 +9,14 @@ export default async function StudioLayout({ children }: { children: React.React
'@type': 'Organization',
name: 'Sim',
url: 'https://sim.ai',
description:
'Sim is an open-source platform for building, testing, and deploying AI agent workflows.',
logo: 'https://sim.ai/logo/primary/small.png',
sameAs: ['https://x.com/simdotai'],
sameAs: [
'https://x.com/simdotai',
'https://github.com/simstudioai/sim',
'https://www.linkedin.com/company/simdotai',
],
}
const websiteJsonLd = {
@@ -18,11 +24,6 @@ export default async function StudioLayout({ children }: { children: React.React
'@type': 'WebSite',
name: 'Sim',
url: 'https://sim.ai',
potentialAction: {
'@type': 'SearchAction',
target: 'https://sim.ai/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
}
return (

View File

@@ -1,11 +1,60 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import { getAllPostMeta } from '@/lib/blog/registry'
import { buildCollectionPageJsonLd } from '@/lib/blog/seo'
export const metadata: Metadata = {
title: 'Blog',
description: 'Announcements, insights, and guides from the Sim team.',
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
}): Promise<Metadata> {
const { page, tag } = await searchParams
const pageNum = Math.max(1, Number(page || 1))
const titleParts = ['Blog']
if (tag) titleParts.push(tag)
if (pageNum > 1) titleParts.push(`Page ${pageNum}`)
const title = titleParts.join(' — ')
const description = tag
? `Sim blog posts tagged "${tag}" — insights and guides for building AI agent workflows.`
: 'Announcements, insights, and guides for building AI agent workflows.'
const canonicalParams = new URLSearchParams()
if (tag) canonicalParams.set('tag', tag)
if (pageNum > 1) canonicalParams.set('page', String(pageNum))
const qs = canonicalParams.toString()
const canonical = `https://sim.ai/blog${qs ? `?${qs}` : ''}`
return {
title,
description,
alternates: { canonical },
openGraph: {
title: `${title} | Sim`,
description,
url: canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
images: [
{
url: 'https://sim.ai/logo/primary/medium.png',
width: 1200,
height: 630,
alt: 'Sim Blog',
},
],
},
twitter: {
card: 'summary_large_image',
title: `${title} | Sim`,
description,
site: '@simdotai',
},
}
}
export const revalidate = 3600
@@ -37,19 +86,13 @@ export default async function BlogIndex({
const featured = pageNum === 1 ? posts.slice(0, 3) : []
const remaining = pageNum === 1 ? posts.slice(3) : posts
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Sim Blog',
url: 'https://sim.ai/blog',
description: 'Announcements, insights, and guides for building AI agent workflows.',
}
const collectionJsonLd = buildCollectionPageJsonLd()
return (
<section className='bg-[var(--landing-bg)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
/>
{/* Section header */}
@@ -81,7 +124,7 @@ export default async function BlogIndex({
{/* Featured posts */}
{featured.length > 0 && (
<>
<div className='flex'>
<nav aria-label='Featured posts' className='flex'>
{featured.map((p, index) => (
<Link
key={p.slug}
@@ -89,11 +132,14 @@ export default async function BlogIndex({
className='group flex flex-1 flex-col gap-4 border-[var(--landing-bg-elevated)] p-6 transition-colors hover:bg-[var(--landing-bg-elevated)] md:border-l md:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<img
<Image
src={p.ogImage}
alt={p.title}
className='h-full w-full object-cover'
loading={index < 3 ? 'eager' : 'lazy'}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
@@ -112,7 +158,7 @@ export default async function BlogIndex({
</div>
</Link>
))}
</div>
</nav>
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />
</>
@@ -151,12 +197,14 @@ export default async function BlogIndex({
</div>
{/* Image */}
<div className='hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<img
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<Image
src={p.ogImage}
alt={p.title}
className='h-full w-full object-cover'
loading='lazy'
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
@@ -166,11 +214,12 @@ export default async function BlogIndex({
{/* Pagination */}
{totalPages > 1 && (
<div className='px-6 py-8'>
<nav aria-label='Pagination' className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
rel='prev'
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Previous
@@ -182,13 +231,14 @@ export default async function BlogIndex({
{pageNum < totalPages && (
<Link
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
rel='next'
className='rounded-[5px] border border-[var(--landing-border-strong)] px-3 py-1 text-[var(--landing-text)] text-sm transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Next
</Link>
)}
</div>
</div>
</nav>
)}
</div>

View File

@@ -7,13 +7,18 @@ export async function GET() {
const posts = await getAllPostMeta()
const items = posts.slice(0, 50)
const site = 'https://sim.ai'
const lastBuildDate =
items.length > 0 ? new Date(items[0].date).toUTCString() : new Date().toUTCString()
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Sim Blog</title>
<link>${site}</link>
<description>Announcements, insights, and guides for AI agent workflows.</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<atom:link href="${site}/blog/rss.xml" rel="self" type="application/rss+xml" />
${items
.map(
(p) => `
@@ -26,6 +31,7 @@ export async function GET() {
${(p.authors || [p.author])
.map((a) => `<author><![CDATA[${a.name}${a.url ? ` (${a.url})` : ''}]]></author>`)
.join('\n')}
${p.tags.map((t) => `<category><![CDATA[${t}]]></category>`).join('\n ')}
</item>`
)
.join('')}

View File

@@ -4,12 +4,42 @@ import { getAllTags } from '@/lib/blog/registry'
export const metadata: Metadata = {
title: 'Tags',
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
alternates: { canonical: 'https://sim.ai/blog/tags' },
openGraph: {
title: 'Blog Tags | Sim',
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
url: 'https://sim.ai/blog/tags',
siteName: 'Sim',
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary',
title: 'Blog Tags | Sim',
description: 'Browse Sim blog posts by topic — AI agents, workflows, integrations, and more.',
site: '@simdotai',
},
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://sim.ai/blog' },
{ '@type': 'ListItem', position: 3, name: 'Tags', item: 'https://sim.ai/blog/tags' },
],
}
export default async function TagsIndex() {
const tags = await getAllTags()
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<h1 className='mb-6 font-[500] text-[32px] text-[var(--landing-text)] leading-tight'>
Browse by tag
</h1>

View File

@@ -7,7 +7,7 @@ import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
const logger = createLogger('github-stars')
const INITIAL_STARS = '27k'
const INITIAL_STARS = '27.6k'
/**
* Client component that displays GitHub stars count.

View File

@@ -45,6 +45,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{
url: `${baseUrl}/blog`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${baseUrl}/blog/tags`,
@@ -72,6 +74,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const blogPages: MetadataRoute.Sitemap = posts.map((p) => ({
url: p.canonical,
lastModified: new Date(p.updated ?? p.date),
changeFrequency: 'weekly',
priority: 0.7,
}))
return [

View File

@@ -1,13 +1,19 @@
export function FAQ({ items }: { items: { q: string; a: string }[] }) {
if (!items || items.length === 0) return null
return (
<section className='mt-12'>
<section className='mt-12' itemScope itemType='https://schema.org/FAQPage'>
<h2 className='mb-4 font-medium text-[24px] text-[var(--landing-text)]'>FAQ</h2>
<div className='space-y-6'>
{items.map((it, i) => (
<div key={i}>
<h3 className='mb-2 font-medium text-[20px] text-[var(--landing-text)]'>{it.q}</h3>
<p className='text-[19px] text-[var(--text-subtle)] leading-relaxed'>{it.a}</p>
<div key={i} itemScope itemType='https://schema.org/Question' itemProp='mainEntity'>
<h3 className='mb-2 font-medium text-[20px] text-[var(--landing-text)]' itemProp='name'>
{it.q}
</h3>
<div itemScope itemType='https://schema.org/Answer' itemProp='acceptedAnswer'>
<p className='text-[19px] text-[var(--text-subtle)] leading-relaxed' itemProp='text'>
{it.a}
</p>
</div>
</div>
))}
</div>

View File

@@ -59,8 +59,15 @@ async function scanFrontmatters(): Promise<BlogMeta[]> {
.catch(() => false)
if (!hasMdx) continue
const raw = await fs.readFile(mdxPath, 'utf-8')
const { data } = matter(raw)
const { data, content: mdxContent } = matter(raw)
const fm = BlogFrontmatterSchema.parse(data)
const wordCount = mdxContent
.replace(/```[\s\S]*?```/g, '')
.replace(/import\s+.*?from\s+['"].*?['"]/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[#*_~`[\]()!|>-]/g, '')
.split(/\s+/)
.filter((w) => w.length > 0).length
const authors = fm.authors.map((id) => authorsMap[id]).filter(Boolean)
if (authors.length === 0) throw new Error(`Authors not found for "${slug}"`)
results.push({
@@ -79,6 +86,7 @@ async function scanFrontmatters(): Promise<BlogMeta[]> {
about: fm.about,
timeRequired: fm.timeRequired,
faq: fm.faq,
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
})

View File

@@ -35,6 +35,7 @@ export const BlogFrontmatterSchema = z
)
.optional(),
canonical: z.string().url(),
wordCount: z.number().int().positive().optional(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
})
@@ -57,6 +58,7 @@ export interface BlogMeta {
about?: string[]
timeRequired?: string
faq?: { q: string; a: string }[]
wordCount?: number
canonical: string
draft: boolean
featured: boolean

View File

@@ -59,27 +59,33 @@ export function buildPostMetadata(post: BlogMeta): Metadata {
export function buildArticleJsonLd(post: BlogMeta) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@type': 'TechArticle',
url: post.canonical,
headline: post.title,
description: post.description,
image: [
{
'@type': 'ImageObject',
url: post.ogImage,
width: 1200,
height: 630,
caption: post.ogAlt || post.title,
},
],
datePublished: post.date,
dateModified: post.updated ?? post.date,
wordCount: post.wordCount,
proficiencyLevel: 'Beginner',
author: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
'@type': 'Person',
name: a.name,
url: a.url,
...(a.url ? { sameAs: [a.url] } : {}),
})),
publisher: {
'@type': 'Organization',
name: 'Sim',
url: 'https://sim.ai',
logo: {
'@type': 'ImageObject',
url: 'https://sim.ai/logo/primary/medium.png',
@@ -104,7 +110,6 @@ export function buildArticleJsonLd(post: BlogMeta) {
export function buildBreadcrumbJsonLd(post: BlogMeta) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
@@ -117,7 +122,6 @@ export function buildBreadcrumbJsonLd(post: BlogMeta) {
export function buildFaqJsonLd(items: { q: string; a: string }[] | undefined) {
if (!items || items.length === 0) return null
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((it) => ({
'@type': 'Question',
@@ -127,6 +131,45 @@ export function buildFaqJsonLd(items: { q: string; a: string }[] | undefined) {
}
}
export function buildPostGraphJsonLd(post: BlogMeta) {
const graph: Record<string, unknown>[] = [buildArticleJsonLd(post), buildBreadcrumbJsonLd(post)]
const faq = buildFaqJsonLd(post.faq)
if (faq) {
graph.push(faq)
}
return {
'@context': 'https://schema.org',
'@graph': graph,
}
}
export function buildCollectionPageJsonLd() {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Sim Blog',
url: 'https://sim.ai/blog',
description: 'Announcements, insights, and guides for building AI agent workflows.',
publisher: {
'@type': 'Organization',
name: 'Sim',
url: 'https://sim.ai',
logo: {
'@type': 'ImageObject',
url: 'https://sim.ai/logo/primary/medium.png',
},
},
inLanguage: 'en-US',
isPartOf: {
'@type': 'WebSite',
name: 'Sim',
url: 'https://sim.ai',
},
}
}
export function buildBlogJsonLd() {
return {
'@context': 'https://schema.org',

38
apps/sim/public/llms.txt Normal file
View File

@@ -0,0 +1,38 @@
# Sim
Sim is an open-source platform for building, testing, and deploying AI agent workflows visually. Create powerful AI agents, automation pipelines, and data processing workflows by connecting blocks on a canvas.
## Key Facts
- Website: https://sim.ai
- GitHub: https://github.com/simstudioai/sim
- Documentation: https://docs.sim.ai
- License: Apache 2.0
- Category: AI workflow automation, developer tools
## What Sim Does
- Visual workflow builder with drag-and-drop interface for AI agents
- 80+ built-in integrations (OpenAI, Anthropic, Slack, Gmail, GitHub, and more)
- Real-time team collaboration on workflows
- Multiple deployment options: cloud-hosted or self-hosted
- Custom integrations via MCP (Model Context Protocol)
- API and SDK access (TypeScript, Python)
- Workflow execution engine with logging and debugging
## Blog
The Sim blog covers announcements, technical deep-dives, and guides for building AI agent workflows.
- Blog: https://sim.ai/blog
- RSS: https://sim.ai/blog/rss.xml
## Documentation Sections
- Getting Started: https://docs.sim.ai/getting-started
- Blocks: https://docs.sim.ai/blocks
- Tools & Integrations: https://docs.sim.ai/tools
- Webhooks: https://docs.sim.ai/webhooks
- MCP Protocol: https://docs.sim.ai/mcp
- Deployment: https://docs.sim.ai/deployment
- SDKs: https://docs.sim.ai/sdks