Skip to content

Commit 885ec8c

Browse files
authored
Merge pull request #174 from aicodingstack/codex/seo
fix(seo): align pages with organic search intent
2 parents cdc8990 + 6064f84 commit 885ec8c

33 files changed

Lines changed: 315 additions & 87 deletions

data/data-health.json

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,59 +73,59 @@
7373
},
7474
"translationsByLocale": {
7575
"de": {
76-
"totalStrings": 411,
76+
"totalStrings": 416,
7777
"matchingEnglish": 113,
78-
"matchingEnglishPercent": 27.5
78+
"matchingEnglishPercent": 27.2
7979
},
8080
"es": {
81-
"totalStrings": 411,
81+
"totalStrings": 416,
8282
"matchingEnglish": 104,
83-
"matchingEnglishPercent": 25.3
83+
"matchingEnglishPercent": 25
8484
},
8585
"fr": {
86-
"totalStrings": 411,
86+
"totalStrings": 416,
8787
"matchingEnglish": 115,
88-
"matchingEnglishPercent": 28
88+
"matchingEnglishPercent": 27.6
8989
},
9090
"id": {
91-
"totalStrings": 411,
91+
"totalStrings": 416,
9292
"matchingEnglish": 149,
93-
"matchingEnglishPercent": 36.3
93+
"matchingEnglishPercent": 35.8
9494
},
9595
"ja": {
96-
"totalStrings": 411,
96+
"totalStrings": 416,
9797
"matchingEnglish": 107,
98-
"matchingEnglishPercent": 26
98+
"matchingEnglishPercent": 25.7
9999
},
100100
"ko": {
101-
"totalStrings": 411,
101+
"totalStrings": 416,
102102
"matchingEnglish": 115,
103-
"matchingEnglishPercent": 28
103+
"matchingEnglishPercent": 27.6
104104
},
105105
"pt": {
106-
"totalStrings": 411,
106+
"totalStrings": 416,
107107
"matchingEnglish": 114,
108-
"matchingEnglishPercent": 27.7
108+
"matchingEnglishPercent": 27.4
109109
},
110110
"ru": {
111-
"totalStrings": 411,
111+
"totalStrings": 416,
112112
"matchingEnglish": 124,
113-
"matchingEnglishPercent": 30.2
113+
"matchingEnglishPercent": 29.8
114114
},
115115
"tr": {
116-
"totalStrings": 411,
116+
"totalStrings": 416,
117117
"matchingEnglish": 125,
118-
"matchingEnglishPercent": 30.4
118+
"matchingEnglishPercent": 30
119119
},
120120
"zh-Hans": {
121-
"totalStrings": 411,
121+
"totalStrings": 416,
122122
"matchingEnglish": 98,
123-
"matchingEnglishPercent": 23.8
123+
"matchingEnglishPercent": 23.6
124124
},
125125
"zh-Hant": {
126-
"totalStrings": 411,
126+
"totalStrings": 416,
127127
"matchingEnglish": 98,
128-
"matchingEnglishPercent": 23.8
128+
"matchingEnglishPercent": 23.6
129129
}
130130
},
131131
"issues": [

docs/DATA-HEALTH.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,17 @@ Exact English matches are a triage signal; product names and technical terms can
3737

3838
| Locale | Comparable strings | Exact English matches | Match rate |
3939
| --- | ---: | ---: | ---: |
40-
| de | 411 | 113 | 27.5% |
41-
| es | 411 | 104 | 25.3% |
42-
| fr | 411 | 115 | 28% |
43-
| id | 411 | 149 | 36.3% |
44-
| ja | 411 | 107 | 26% |
45-
| ko | 411 | 115 | 28% |
46-
| pt | 411 | 114 | 27.7% |
47-
| ru | 411 | 124 | 30.2% |
48-
| tr | 411 | 125 | 30.4% |
49-
| zh-Hans | 411 | 98 | 23.8% |
50-
| zh-Hant | 411 | 98 | 23.8% |
40+
| de | 416 | 113 | 27.2% |
41+
| es | 416 | 104 | 25% |
42+
| fr | 416 | 115 | 27.6% |
43+
| id | 416 | 149 | 35.8% |
44+
| ja | 416 | 107 | 25.7% |
45+
| ko | 416 | 115 | 27.6% |
46+
| pt | 416 | 114 | 27.4% |
47+
| ru | 416 | 124 | 29.8% |
48+
| tr | 416 | 125 | 30% |
49+
| zh-Hans | 416 | 98 | 23.6% |
50+
| zh-Hant | 416 | 98 | 23.6% |
5151

5252
## Backlog by Issue Type
5353

docs/SEO-SEARCH-OPPORTUNITY-MAP.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Organic Search Opportunity Map
2+
3+
**Measurement window:** April 27–July 26, 2026
4+
5+
**Search market:** Google, English-led with localized landing pages
6+
7+
**Primary goal:** Increase non-brand impressions, top-10 rankings, and organic click-through rate
8+
9+
## Search Performance Baseline
10+
11+
Google Search Console reported:
12+
13+
| Metric | Value |
14+
|---|---:|
15+
| Clicks | 2 |
16+
| Impressions | 601 |
17+
| Click-through rate | 0.3% |
18+
| Average position | 39.2 |
19+
20+
The strongest existing landing pages were:
21+
22+
| Landing page | Clicks | Impressions | Average position | Opportunity |
23+
|---|---:|---:|---:|---|
24+
| `/models` | 1 | 67 | 13.5 | Highest-priority striking-distance page |
25+
| `/model-providers` | 0 | 16 | 14.3 | Striking-distance category page |
26+
| `/` | 1 | 46 | 36.3 | Broad discovery page |
27+
| `/zh-Hant/ides/vscode` | 0 | 108 | 32.6 | High-impression product page |
28+
| `/ides/kiro` | 0 | 77 | 59.8 | Brand/product content opportunity |
29+
| `/zh-Hant/vendors/xai` | 0 | 29 | 33.8 | Brand/vendor content opportunity |
30+
31+
## Keyword-to-Landing-Page Map
32+
33+
Search volume is marked `N/A` because no reliable keyword-volume provider was available. Search Console impressions are first-party observations, not monthly search volume.
34+
35+
| Search theme | Observed queries | Intent | Measured signal | Target landing page | Action |
36+
|---|---|---|---|---|---|
37+
| AI coding models | `AI coding models`, `LLM for coding`, model names | Commercial investigation | `/models`: 67 impressions, position 13.5 | `/models` | Improve title, description, comparison guidance, and links to model comparison/providers |
38+
| Model API providers | `model provider`, `LLM API provider` | Commercial investigation | `/model-providers`: 16 impressions, position 14.3 | `/model-providers` | Expand comparison criteria and provider-selection guidance |
39+
| VS Code with AI | `vscode ai` (69 impressions), `visual code ai ide` (12) | Product discovery | Query positions 24.7 and 19.4 | `/ides/vscode` and localized equivalents | Add AI feature coverage and clarify the relationship between VS Code and AI extensions |
40+
| Codex CLI | `codex cli` (8 impressions), `codex-cli`, `does codex have a cli` | Navigational and informational | Positions 14.9–16.0 | `/clis/codex-cli` | Add a direct answer, setup overview, supported workflows, and official-source references |
41+
| Kiro IDE | `aws kiro` (18), `kiro ai` (17), `kiro ide` (13), `kiro aws` (10) | Product discovery | Positions 53.9–68.7 | `/ides/kiro` | Expand AWS/Kiro entity context, use cases, pricing, and comparison links |
42+
| AI coding tools | `aicoding`, `ai coding stack`, `coding stack` | Broad discovery | Homepage: 46 impressions, position 36.3 | `/` | Target directory and comparison intent in the title and description |
43+
| AI coding stack | `ai coding stack`, `coding stack` | Category education | Query positions 40.0–53.7 | `/ai-coding-stack` | Keep as the ecosystem/category architecture page to avoid homepage cannibalization |
44+
| OpenCode | `open code`, `opencode` | Navigational | Positions 9.0–10.0 | Relevant OpenCode detail page | Preserve exact entity naming and strengthen internal links |
45+
46+
## SERP and Content-Gap Findings
47+
48+
For broad commercial searches, current results are dominated by dated, comparison-led pages with a quick answer, consistent evaluation criteria, pricing, best-fit guidance, trade-offs, and a comparison table. Representative results include:
49+
50+
- [Every AI Coding Tool Compared: The 2026 Matrix](https://www.developersdigest.tech/blog/ai-coding-tools-comparison-matrix-2026)
51+
- [The Best AI Coding Assistants in 2026, Compared](https://daily.dev/blog/best-ai-coding-assistants-comparison/)
52+
- [The 9 best AI coding tools in 2026](https://zapier.com/blog/ai-coding-tools/)
53+
- [Best LLM for Coding in 2026: Ranked by Benchmarks](https://www.tembo.io/blog/best-llm-for-coding)
54+
55+
AI Coding Stack has stronger structured catalog data than many editorial competitors, but its category pages provide less decision guidance. The defensible gap is a source-backed comparison layer built on the existing manifests:
56+
57+
1. Define stable comparison criteria for each category.
58+
2. Explain which fields matter for different workflows.
59+
3. Link category guidance to the existing comparison tools and verified detail pages.
60+
4. Publish recommendation content only when the supporting data and methodology are explicit.
61+
62+
## Prioritized Work
63+
64+
| Priority | Work item | Rationale | Status |
65+
|---|---|---|---|
66+
| P0 | Remove repeated brand names and excessive examples from page titles | Existing list titles exceed normal snippet length and repeat `AI Coding Stack` | Implemented in working tree |
67+
| P0 | Optimize `/models` for comparison intent | It is the strongest striking-distance page | Implemented in working tree |
68+
| P0 | Complete `/models/compare` metadata | The comparison route lacked canonical, hreflang, robots, and complete social metadata | Implemented in working tree |
69+
| P0 | Reposition homepage metadata around “AI coding tools directory and comparisons” | Aligns the broad landing page with non-brand discovery intent | Implemented in working tree |
70+
| P1 | Expand `/model-providers` selection guidance | Average position 14.3 indicates near-term potential | Planned |
71+
| P1 | Expand `/clis/codex-cli` with a direct answer and setup coverage | Multiple queries rank between positions 14.9 and 16.0 | Planned |
72+
| P1 | Expand `/ides/vscode` for “VS Code AI” intent | Highest observed product-query impressions | Planned |
73+
| P1 | Expand `/ides/kiro` for AWS/Kiro entity coverage | Multiple related queries already receive impressions | Planned |
74+
| P2 | Create a source-backed cross-category comparison guide | Broad SERPs reward tested comparison and best-fit guidance | Planned |
75+
76+
## On-Page Audit: `/models`
77+
78+
The baseline score applies the repository's weighted on-page rubric to the production page observed during the measurement period. The working-tree score reflects the implemented metadata and guidance changes; production scoring remains unchanged until deployment.
79+
80+
| Section | Weight | Baseline | Working tree | Finding |
81+
|---|---:|---:|---:|---|
82+
| Title tag | 15 | 4 | 14 | Baseline title was long, stale, and repeated the brand; working title is concise and localized |
83+
| Meta description | 5 | 2 | 5 | Baseline description was too short; working description explains comparison criteria |
84+
| Header structure | 10 | 8 | 9 | One H1 and lifecycle H2s; working tree adds a descriptive comparison H2 |
85+
| Content quality | 25 | 12 | 17 | Catalog data is useful; working tree adds decision guidance but is not yet a full editorial guide |
86+
| Keyword optimization | 15 | 9 | 13 | Working copy covers coding models, context, pricing, lifecycle, providers, and comparison |
87+
| Internal/external links | 10 | 8 | 9 | Working tree adds contextual links to comparison and provider pages |
88+
| Image optimization | 10 | 10 | 10 | The catalog does not depend on content images; route-level social imagery is separate |
89+
| Page-level technical | 10 | 9 | 10 | Canonical and locale alternates remain intact; the comparison destination now receives complete metadata |
90+
| **Overall** | **100** | **62 (C)** | **87 (A)** | Production changes after the GitHub Actions deployment |
91+
92+
## Measurement Plan
93+
94+
After deployment, compare 28-day periods in Search Console:
95+
96+
- `/models`: impressions, CTR, and average position
97+
- `/models/compare`: indexed state and query discovery
98+
- Homepage: non-brand impressions for AI coding tool terms
99+
- `/clis/codex-cli`, `/ides/vscode`, and `/ides/kiro`: query-level position and CTR
100+
101+
The first decision threshold is movement into positions 4–10 for `/models`. The next content batch should use the first 28 days of post-deployment data rather than assumed keyword volume.

src/app/[locale]/models/compare/page.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PageHeader from '@/components/PageHeader'
44
import type { Locale } from '@/i18n/config'
55
import { PageLayout } from '@/layouts/PageLayout'
66
import { modelsData as allModels } from '@/lib/generated'
7+
import { generateStaticPageMetadata } from '@/lib/metadata'
78
import type { ManifestModel } from '@/types/manifests'
89
import ComparisonPageClient from './[models]/page.client'
910

@@ -17,15 +18,15 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
1718
const { locale } = await params
1819
const t = await getTranslations({ locale, namespace: 'pages.comparison' })
1920

20-
return {
21-
title: t('models.title'),
21+
return generateStaticPageMetadata({
22+
locale: locale as Locale,
23+
basePath: 'models/compare',
24+
title: `${t('models.title')} ${new Date().getUTCFullYear()}`,
2225
description: t('models.subtitle'),
23-
openGraph: {
24-
title: t('models.title'),
25-
description: t('models.subtitle'),
26-
type: 'website',
27-
},
28-
}
26+
keywords:
27+
'AI coding model comparison, compare coding LLMs, LLM pricing comparison, context window comparison',
28+
ogType: 'website',
29+
})
2930
}
3031

3132
export default async function ComparePage({ params }: { params: Promise<{ locale: string }> }) {

src/app/[locale]/models/page.client.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ export default function ModelsPageClient({ locale }: Props) {
109109

110110
<StackTabs activeStack="models" locale={locale} />
111111

112+
<section className="mb-[var(--spacing-lg)] max-w-6xl border-y border-[var(--color-border)] py-[var(--spacing-md)]">
113+
<h2 className="mb-[var(--spacing-xs)] text-lg font-semibold tracking-tight">
114+
{tPage('guide.title')}
115+
</h2>
116+
<p className="max-w-4xl text-sm leading-relaxed text-[var(--color-text-secondary)]">
117+
{tPage('guide.description')}
118+
</p>
119+
<div className="mt-[var(--spacing-sm)] flex flex-wrap gap-[var(--spacing-md)] text-sm">
120+
<Link
121+
href={buildModelComparisonPath(selectedIds)}
122+
className="text-[var(--color-text-secondary)] underline-offset-4 hover:text-[var(--color-text)] hover:underline"
123+
>
124+
{tPage('guide.compareModels')}
125+
</Link>
126+
<Link
127+
href="/model-providers"
128+
className="text-[var(--color-text-secondary)] underline-offset-4 hover:text-[var(--color-text)] hover:underline"
129+
>
130+
{tPage('guide.exploreProviders')}
131+
</Link>
132+
</div>
133+
</section>
134+
112135
{/* Search Box */}
113136
<div className="mb-[var(--spacing-md)]">
114137
<input

src/app/[locale]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export async function generateMetadata({ params }: LocalePageProps) {
1616
const { locale } = await params
1717
const tPage = await getTranslations({ locale, namespace: 'pages.home.meta' })
1818

19-
const title = buildTitle({ title: tPage('title'), includeSiteName: false })
19+
const title = buildTitle({ title: tPage('title'), includeSiteName: true })
2020
const description = tPage('description')
2121

2222
return generateStaticPageMetadata({

src/lib/metadata/generators.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { formatPrimaryTokenRate } from '@/lib/model-pricing'
1111
import type { ManifestTokenPricing, ModelLifecycle } from '@/types/manifests'
1212
import {
1313
CATEGORY_DISPLAY_NAMES,
14-
CATEGORY_EXAMPLES,
1514
CATEGORY_SEO_KEYWORDS,
1615
type Category,
1716
type Locale,
@@ -210,17 +209,13 @@ export async function generateListPageMetadata(options: ListPageMetadataParams):
210209
const tPage = await getTranslations({ locale, namespace: translationNamespace })
211210

212211
const translatedTitle = tPage('title')
213-
const description = tPage('subtitle')
212+
const description = tPage.has('meta.description') ? tPage('meta.description') : tPage('subtitle')
214213

215214
// Build SEO-optimized title
216-
const categoryExamples = CATEGORY_EXAMPLES[category as keyof typeof CATEGORY_EXAMPLES]
217215
const categoryKeywords = CATEGORY_SEO_KEYWORDS[category as keyof typeof CATEGORY_SEO_KEYWORDS]
218216

219217
const title = buildListPageTitle({
220218
translatedTitle,
221-
categoryName:
222-
CATEGORY_DISPLAY_NAMES[category as keyof typeof CATEGORY_DISPLAY_NAMES] || translatedTitle,
223-
examples: categoryExamples ? [...categoryExamples] : [],
224219
year: METADATA_DEFAULTS.currentYear,
225220
})
226221

@@ -383,7 +378,7 @@ export async function generateComparisonMetadata(
383378
const tPage = await getTranslations({ locale, namespace: 'pages.comparison' })
384379

385380
// Build title and description using category-specific translations
386-
const title = `${tPage(`${category}.title`)} - ${categoryName} Comparison | ${METADATA_DEFAULTS.siteName}`
381+
const title = `${tPage(`${category}.title`)} ${METADATA_DEFAULTS.currentYear}`
387382
const description = tPage(`${category}.subtitle`)
388383

389384
// Build keywords
@@ -423,7 +418,7 @@ export async function generateArticleMetadata(options: ArticleMetadataParams): P
423418
const { locale, slug, article } = options
424419

425420
// Build title
426-
const title = `${article.title} | ${METADATA_DEFAULTS.siteName} Articles`
421+
const title = article.title
427422

428423
// Build description
429424
const description = article.description
@@ -458,7 +453,7 @@ export async function generateDocsMetadata(options: DocsMetadataParams): Promise
458453
const { locale, slug, doc } = options
459454

460455
// Build title
461-
const title = `${doc.title} | ${METADATA_DEFAULTS.siteName} Documentation`
456+
const title = `${doc.title} Documentation`
462457

463458
// Build description
464459
const description = doc.description

src/lib/metadata/helpers.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function buildTitle(options: {
5353
const {
5454
title,
5555
suffix,
56-
includeSiteName = true,
56+
includeSiteName = false,
5757
separator = METADATA_DEFAULTS.titleSeparator,
5858
} = options
5959

@@ -71,30 +71,13 @@ export function buildTitle(options: {
7171
/**
7272
* Builds SEO-optimized list page title
7373
*/
74-
export function buildListPageTitle(options: {
75-
translatedTitle: string
76-
categoryName: string
77-
examples?: string[]
78-
year?: number
79-
}): string {
80-
const {
81-
translatedTitle,
82-
categoryName,
83-
examples = [],
84-
year = METADATA_DEFAULTS.currentYear,
85-
} = options
86-
87-
const parts = [translatedTitle, `-`, `Best ${categoryName} ${year}`]
88-
89-
if (examples.length > 0) {
90-
parts.push(METADATA_DEFAULTS.listSeparator)
91-
parts.push(examples.join(', '))
92-
}
93-
94-
parts.push(METADATA_DEFAULTS.titleSeparator)
95-
parts.push(METADATA_DEFAULTS.siteName)
74+
export function buildListPageTitle(options: { translatedTitle: string; year?: number }): string {
75+
const { translatedTitle, year = METADATA_DEFAULTS.currentYear } = options
9676

97-
return parts.join(' ').replace(/\s+/g, ' ') // Clean up multiple spaces
77+
// The locale layout appends the site name through its title template.
78+
// Keep the page-specific title concise so search results do not repeat the
79+
// brand or truncate the primary query.
80+
return `${translatedTitle} ${year}`
9881
}
9982

10083
/**

0 commit comments

Comments
 (0)