From a2563d81c2de7e376ec005f9f9bbdc4edbf4d130 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 01:35:51 -0700 Subject: [PATCH 1/7] Fix SEO indexing: add hreflang, canonicals, sitemap per-locale entries Google Search Console showed 380 not-indexed vs 86 indexed pages. Root causes: missing hreflang tags on rendered pages (only in sitemap), no canonical on homepage, inconsistent canonicals wiping parent hreflang, sitemap only listing English URLs, trailing slash duplicates, and _next/static chunks being crawled as pages. Changes: - Add buildAlternates() utility for consistent canonical + hreflang - Add hreflang tags to all pages via alternates.languages in metadata - Add self-referencing canonical URLs to every page (homepage had none) - Expand sitemap to emit separate entries for each locale - Add missing /docs/custom-commands to sitemap - Remove skipTrailingSlashRedirect to normalize trailing slashes - Block /_next/ in robots.txt to stop chunk crawling --- web/app/[locale]/(legal)/eula/page.tsx | 17 ++++++++------ .../[locale]/(legal)/privacy-policy/page.tsx | 15 ++++++++----- .../(legal)/terms-of-service/page.tsx | 17 ++++++++------ web/app/[locale]/blog/cmd-shift-u/page.tsx | 5 ++--- .../[locale]/blog/introducing-cmux/page.tsx | 5 ++--- web/app/[locale]/blog/layout.tsx | 5 ++--- web/app/[locale]/blog/show-hn-launch/page.tsx | 5 ++--- web/app/[locale]/blog/zen-of-cmux/page.tsx | 5 ++--- web/app/[locale]/community/page.tsx | 3 ++- web/app/[locale]/docs/layout.tsx | 5 ++--- web/app/[locale]/layout.tsx | 2 ++ web/app/[locale]/nightly/page.tsx | 3 ++- web/app/[locale]/wall-of-love/page.tsx | 3 ++- web/app/robots.ts | 2 +- web/app/sitemap.ts | 20 +++++++++++------ web/i18n/seo.ts | 22 +++++++++++++++++++ web/next.config.ts | 1 - 17 files changed, 85 insertions(+), 50 deletions(-) create mode 100644 web/i18n/seo.ts diff --git a/web/app/[locale]/(legal)/eula/page.tsx b/web/app/[locale]/(legal)/eula/page.tsx index ddc9ae0a0937..a8f10e52264c 100644 --- a/web/app/[locale]/(legal)/eula/page.tsx +++ b/web/app/[locale]/(legal)/eula/page.tsx @@ -1,10 +1,13 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "EULA — cmux", - description: "End-User License Agreement for cmux", - alternates: { canonical: "./" }, -}; +import { buildAlternates } from "../../../../i18n/seo"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return { + title: "EULA — cmux", + description: "End-User License Agreement for cmux", + alternates: buildAlternates(locale, "/eula"), + }; +} export default function EulaPage() { return ( diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx index f094072899fa..9d65de5586cb 100644 --- a/web/app/[locale]/(legal)/privacy-policy/page.tsx +++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx @@ -1,11 +1,14 @@ -import type { Metadata } from "next"; +import { buildAlternates } from "../../../../i18n/seo"; import { Link } from "../../../../i18n/navigation"; -export const metadata: Metadata = { - title: "Privacy Policy — cmux", - description: "Privacy policy for cmux", - alternates: { canonical: "./" }, -}; +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return { + title: "Privacy Policy — cmux", + description: "Privacy policy for cmux", + alternates: buildAlternates(locale, "/privacy-policy"), + }; +} export default function PrivacyPolicyPage() { return ( diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx index 56b4b98e8bce..3e1a2b7d5114 100644 --- a/web/app/[locale]/(legal)/terms-of-service/page.tsx +++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx @@ -1,10 +1,13 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Terms of Service — cmux", - description: "Terms of service for cmux", - alternates: { canonical: "./" }, -}; +import { buildAlternates } from "../../../../i18n/seo"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return { + title: "Terms of Service — cmux", + description: "Terms of service for cmux", + alternates: buildAlternates(locale, "/terms-of-service"), + }; +} export default function TermsOfServicePage() { return ( diff --git a/web/app/[locale]/blog/cmd-shift-u/page.tsx b/web/app/[locale]/blog/cmd-shift-u/page.tsx index e87e3fabf211..5cdb1ecfd58a 100644 --- a/web/app/[locale]/blog/cmd-shift-u/page.tsx +++ b/web/app/[locale]/blog/cmd-shift-u/page.tsx @@ -1,11 +1,11 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { Link } from "../../../../i18n/navigation"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "blog.cmdShiftU" }); - const url = locale === "en" ? "/blog/cmd-shift-u" : `/${locale}/blog/cmd-shift-u`; return { title: t("metaTitle"), description: t("metaDescription"), @@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s description: t("metaDescription"), type: "article", publishedTime: "2026-03-04T00:00:00Z", - url, }, twitter: { card: "summary_large_image", title: t("metaTitle"), description: t("metaDescription"), }, - alternates: { canonical: url }, + alternates: buildAlternates(locale, "/blog/cmd-shift-u"), }; } diff --git a/web/app/[locale]/blog/introducing-cmux/page.tsx b/web/app/[locale]/blog/introducing-cmux/page.tsx index 82dadc10b35d..811c07eb3a10 100644 --- a/web/app/[locale]/blog/introducing-cmux/page.tsx +++ b/web/app/[locale]/blog/introducing-cmux/page.tsx @@ -1,11 +1,11 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { Link } from "../../../../i18n/navigation"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "blog.introducingCmux" }); - const url = locale === "en" ? "/blog/introducing-cmux" : `/${locale}/blog/introducing-cmux`; return { title: t("metaTitle"), description: t("metaDescription"), @@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s description: t("metaDescription"), type: "article", publishedTime: "2026-02-12T00:00:00Z", - url, }, twitter: { card: "summary_large_image", title: t("metaTitle"), description: t("metaDescription"), }, - alternates: { canonical: url }, + alternates: buildAlternates(locale, "/blog/introducing-cmux"), }; } diff --git a/web/app/[locale]/blog/layout.tsx b/web/app/[locale]/blog/layout.tsx index 30caa6ccbac6..9a3a18aa8d60 100644 --- a/web/app/[locale]/blog/layout.tsx +++ b/web/app/[locale]/blog/layout.tsx @@ -1,4 +1,5 @@ import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { SiteHeader } from "../components/site-header"; import { BlogPager } from "../components/blog-pager"; import { BlogCTA } from "../components/blog-cta"; @@ -19,9 +20,7 @@ export async function generateMetadata({ siteName: "cmux", type: "article" as const, }, - alternates: { - canonical: "./", - }, + alternates: buildAlternates(locale, "/blog"), }; } diff --git a/web/app/[locale]/blog/show-hn-launch/page.tsx b/web/app/[locale]/blog/show-hn-launch/page.tsx index 71eb3bd9f4ac..3bf1041af98b 100644 --- a/web/app/[locale]/blog/show-hn-launch/page.tsx +++ b/web/app/[locale]/blog/show-hn-launch/page.tsx @@ -1,6 +1,7 @@ import Image from "next/image"; import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { Link } from "../../../../i18n/navigation"; import { Tweet } from "react-tweet"; import starHistory from "./star-history.png"; @@ -8,7 +9,6 @@ import starHistory from "./star-history.png"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "blog.showHnLaunch" }); - const url = locale === "en" ? "/blog/show-hn-launch" : `/${locale}/blog/show-hn-launch`; return { title: t("metaTitle"), description: t("metaDescription"), @@ -22,14 +22,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s description: t("metaDescription"), type: "article", publishedTime: "2026-02-21T00:00:00Z", - url, }, twitter: { card: "summary_large_image", title: t("metaTitle"), description: t("metaDescription"), }, - alternates: { canonical: url }, + alternates: buildAlternates(locale, "/blog/show-hn-launch"), }; } diff --git a/web/app/[locale]/blog/zen-of-cmux/page.tsx b/web/app/[locale]/blog/zen-of-cmux/page.tsx index cff31a9c5446..74248fdc6ccf 100644 --- a/web/app/[locale]/blog/zen-of-cmux/page.tsx +++ b/web/app/[locale]/blog/zen-of-cmux/page.tsx @@ -1,11 +1,11 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { Link } from "../../../../i18n/navigation"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "blog.zenOfCmux" }); - const url = locale === "en" ? "/blog/zen-of-cmux" : `/${locale}/blog/zen-of-cmux`; return { title: t("metaTitle"), description: t("metaDescription"), @@ -18,14 +18,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s description: t("metaDescription"), type: "article", publishedTime: "2026-02-27T00:00:00Z", - url, }, twitter: { card: "summary_large_image", title: t("metaTitle"), description: t("metaDescription"), }, - alternates: { canonical: url }, + alternates: buildAlternates(locale, "/blog/zen-of-cmux"), }; } diff --git a/web/app/[locale]/community/page.tsx b/web/app/[locale]/community/page.tsx index 3742df3b8913..63244579a29b 100644 --- a/web/app/[locale]/community/page.tsx +++ b/web/app/[locale]/community/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { SiteHeader } from "../components/site-header"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { @@ -8,7 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), - alternates: { canonical: "./" }, + alternates: buildAlternates(locale, "/community"), }; } diff --git a/web/app/[locale]/docs/layout.tsx b/web/app/[locale]/docs/layout.tsx index 571cef006411..1681d9a56ea7 100644 --- a/web/app/[locale]/docs/layout.tsx +++ b/web/app/[locale]/docs/layout.tsx @@ -1,4 +1,5 @@ import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { DocsNav } from "./docs-nav"; import { SiteHeader } from "../components/site-header"; @@ -18,9 +19,7 @@ export async function generateMetadata({ siteName: "cmux", type: "article" as const, }, - alternates: { - canonical: "./", - }, + alternates: buildAlternates(locale, "/docs"), }; } diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx index 32c2cc03c0a8..8f04d6ede0ca 100644 --- a/web/app/[locale]/layout.tsx +++ b/web/app/[locale]/layout.tsx @@ -8,6 +8,7 @@ import { } from "next-intl/server"; import { notFound } from "next/navigation"; import { routing } from "../../i18n/routing"; +import { buildAlternates } from "../../i18n/seo"; import { Providers } from "./providers"; import { DevPanel } from "./components/spacing-control"; import { SiteFooter } from "./components/site-footer"; @@ -61,6 +62,7 @@ export async function generateMetadata({ title: t("title"), description: t("ogDescription"), }, + alternates: buildAlternates(locale, ""), metadataBase: new URL("https://cmux.com"), }; } diff --git a/web/app/[locale]/nightly/page.tsx b/web/app/[locale]/nightly/page.tsx index d3fb1a31c44b..457b2e83c268 100644 --- a/web/app/[locale]/nightly/page.tsx +++ b/web/app/[locale]/nightly/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { SiteHeader } from "../components/site-header"; export async function generateMetadata({ @@ -12,7 +13,7 @@ export async function generateMetadata({ return { title: t("metaTitle"), description: t("metaDescription"), - alternates: { canonical: "./" }, + alternates: buildAlternates(locale, "/nightly"), }; } diff --git a/web/app/[locale]/wall-of-love/page.tsx b/web/app/[locale]/wall-of-love/page.tsx index c1ec7d418f14..3b1c565f864a 100644 --- a/web/app/[locale]/wall-of-love/page.tsx +++ b/web/app/[locale]/wall-of-love/page.tsx @@ -1,5 +1,6 @@ import { useTranslations, useLocale } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { SiteHeader } from "../components/site-header"; import { testimonials, TestimonialCard, getTestimonialTranslation } from "../testimonials"; @@ -9,7 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), - alternates: { canonical: "./" }, + alternates: buildAlternates(locale, "/wall-of-love"), }; } diff --git a/web/app/robots.ts b/web/app/robots.ts index 1b471bcf11c4..0a177d8cf9e7 100644 --- a/web/app/robots.ts +++ b/web/app/robots.ts @@ -2,7 +2,7 @@ import type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { - rules: { userAgent: "*", allow: "/" }, + rules: { userAgent: "*", allow: "/", disallow: "/_next/" }, sitemap: "https://cmux.com/sitemap.xml", }; } diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index ecc6e0bb5e4b..7725e66ffcb4 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: "/docs/getting-started", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.9 }, { path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, + { path: "/docs/custom-commands", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, { path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, @@ -37,13 +38,18 @@ export default function sitemap(): MetadataRoute.Sitemap { } alternates["x-default"] = `${base}${path}`; - entries.push({ - url: `${base}${path}`, - lastModified, - changeFrequency, - priority, - alternates: { languages: alternates }, - }); + // Emit a separate entry for each locale so Google sees every URL declared + for (const locale of locales) { + const url = + locale === "en" ? `${base}${path}` : `${base}/${locale}${path}`; + entries.push({ + url, + lastModified, + changeFrequency, + priority, + alternates: { languages: alternates }, + }); + } } return entries; diff --git a/web/i18n/seo.ts b/web/i18n/seo.ts new file mode 100644 index 000000000000..21683e5a900b --- /dev/null +++ b/web/i18n/seo.ts @@ -0,0 +1,22 @@ +import { locales } from "./routing"; + +const BASE = "https://cmux.com"; + +/** + * Build the full alternates object (canonical + hreflang languages) + * for a given locale and path. Use in every generateMetadata that + * sets alternates so child metadata doesn't wipe parent hreflang. + */ +export function buildAlternates(locale: string, path: string) { + const languages: Record = {}; + for (const loc of locales) { + languages[loc] = + loc === "en" ? `${BASE}${path}` : `${BASE}/${loc}${path}`; + } + languages["x-default"] = `${BASE}${path}`; + + const canonical = + locale === "en" ? `${BASE}${path}` : `${BASE}/${locale}${path}`; + + return { canonical, languages }; +} diff --git a/web/next.config.ts b/web/next.config.ts index 3ab0db0d1115..8328ed229b81 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -5,7 +5,6 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); const nextConfig: NextConfig = { - skipTrailingSlashRedirect: true, images: { remotePatterns: [ { From 9565f22b36271f85bcd0d6085817545fef7b2d70 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:10:29 -0700 Subject: [PATCH 2/7] Add per-page alternates to docs sub-pages and blog index Docs sub-pages and blog index only returned title/description in generateMetadata, so they inherited the parent layout's alternates (pointing to /docs or /blog). Now each page sets its own buildAlternates() with the correct path so canonical and hreflang point to the actual page URL. --- web/app/[locale]/blog/page.tsx | 2 ++ web/app/[locale]/docs/api/page.tsx | 2 ++ web/app/[locale]/docs/browser-automation/page.tsx | 2 ++ web/app/[locale]/docs/changelog/page.tsx | 2 ++ web/app/[locale]/docs/concepts/page.tsx | 2 ++ web/app/[locale]/docs/configuration/page.tsx | 2 ++ web/app/[locale]/docs/custom-commands/page.tsx | 2 ++ web/app/[locale]/docs/getting-started/page.tsx | 2 ++ web/app/[locale]/docs/keyboard-shortcuts/page.tsx | 2 ++ web/app/[locale]/docs/notifications/page.tsx | 2 ++ 10 files changed, 20 insertions(+) diff --git a/web/app/[locale]/blog/page.tsx b/web/app/[locale]/blog/page.tsx index 1fb32d4fc037..f9cf1ac3e2d0 100644 --- a/web/app/[locale]/blog/page.tsx +++ b/web/app/[locale]/blog/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../i18n/seo"; import { Link } from "../../../i18n/navigation"; export async function generateMetadata({ @@ -12,6 +13,7 @@ export async function generateMetadata({ return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/blog"), }; } diff --git a/web/app/[locale]/docs/api/page.tsx b/web/app/[locale]/docs/api/page.tsx index 712a46ef6a95..4152abedffcf 100644 --- a/web/app/[locale]/docs/api/page.tsx +++ b/web/app/[locale]/docs/api/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; @@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/api"), }; } diff --git a/web/app/[locale]/docs/browser-automation/page.tsx b/web/app/[locale]/docs/browser-automation/page.tsx index 32d72eaa26a4..f44cd6b03957 100644 --- a/web/app/[locale]/docs/browser-automation/page.tsx +++ b/web/app/[locale]/docs/browser-automation/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { @@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/browser-automation"), }; } diff --git a/web/app/[locale]/docs/changelog/page.tsx b/web/app/[locale]/docs/changelog/page.tsx index b467cbdd7b5f..0cca42bdf1b7 100644 --- a/web/app/[locale]/docs/changelog/page.tsx +++ b/web/app/[locale]/docs/changelog/page.tsx @@ -3,6 +3,7 @@ import path from "path"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { changelogMedia, type VersionMedia } from "./changelog-media"; /** Read PNG dimensions from the IHDR chunk (bytes 16-23). */ @@ -21,6 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/changelog"), }; } diff --git a/web/app/[locale]/docs/concepts/page.tsx b/web/app/[locale]/docs/concepts/page.tsx index 88d10319f728..3cb390c1edc4 100644 --- a/web/app/[locale]/docs/concepts/page.tsx +++ b/web/app/[locale]/docs/concepts/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { @@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/concepts"), }; } diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 7186f4eb40bb..182ed116b4b2 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; @@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/configuration"), }; } diff --git a/web/app/[locale]/docs/custom-commands/page.tsx b/web/app/[locale]/docs/custom-commands/page.tsx index 048f1b042fdb..aedb01f8f65c 100644 --- a/web/app/[locale]/docs/custom-commands/page.tsx +++ b/web/app/[locale]/docs/custom-commands/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; @@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/custom-commands"), }; } diff --git a/web/app/[locale]/docs/getting-started/page.tsx b/web/app/[locale]/docs/getting-started/page.tsx index 788fff6a4bda..e0bf5194e94a 100644 --- a/web/app/[locale]/docs/getting-started/page.tsx +++ b/web/app/[locale]/docs/getting-started/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; import { DownloadButton } from "../../components/download-button"; @@ -10,6 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/getting-started"), }; } diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 8d2f323c03fc..70b25ea89d3d 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { KeyboardShortcuts } from "../../keyboard-shortcuts"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { @@ -8,6 +9,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/keyboard-shortcuts"), }; } diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index b02de0e124bd..0a337de2250e 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; +import { buildAlternates } from "../../../../i18n/seo"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; @@ -9,6 +10,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s return { title: t("metaTitle"), description: t("metaDescription"), + alternates: buildAlternates(locale, "/docs/notifications"), }; } From 5dbf4ec5396966dc6d462d0c56a25dac2dde3533 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:27:54 -0700 Subject: [PATCH 3/7] Derive openGraph.url from buildAlternates to avoid drift --- web/app/[locale]/layout.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx index 8f04d6ede0ca..ad35d0ce1505 100644 --- a/web/app/[locale]/layout.tsx +++ b/web/app/[locale]/layout.tsx @@ -31,8 +31,7 @@ export async function generateMetadata({ }): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: "meta" }); - const url = - locale === "en" ? "https://cmux.com" : `https://cmux.com/${locale}`; + const alternates = buildAlternates(locale, ""); return { title: t("title"), description: t("description"), @@ -53,7 +52,7 @@ export async function generateMetadata({ openGraph: { title: t("title"), description: t("ogDescription"), - url, + url: alternates.canonical, siteName: "cmux", type: "website", }, @@ -62,7 +61,7 @@ export async function generateMetadata({ title: t("title"), description: t("ogDescription"), }, - alternates: buildAlternates(locale, ""), + alternates, metadataBase: new URL("https://cmux.com"), }; } From 991ecc326481f63cdbecf873e6c93c2b46625716 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:36:49 -0700 Subject: [PATCH 4/7] Redirect non-English legal pages to English, remove from sitemap Legal pages (privacy policy, TOS, EULA) are untranslated English content. Serving them under every locale creates 54 duplicate URLs. Now: - Middleware 301-redirects /ja/privacy-policy etc. to /privacy-policy - Sitemap only includes English URLs for legal pages (no locale variants) - Legal page metadata uses static English-only canonical --- web/app/[locale]/(legal)/eula/page.tsx | 17 +++++++---------- .../[locale]/(legal)/privacy-policy/page.tsx | 15 ++++++--------- .../[locale]/(legal)/terms-of-service/page.tsx | 17 +++++++---------- web/app/sitemap.ts | 13 +++++++++++++ web/proxy.ts | 11 +++++++++++ 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/web/app/[locale]/(legal)/eula/page.tsx b/web/app/[locale]/(legal)/eula/page.tsx index a8f10e52264c..9e0b108f07b4 100644 --- a/web/app/[locale]/(legal)/eula/page.tsx +++ b/web/app/[locale]/(legal)/eula/page.tsx @@ -1,13 +1,10 @@ -import { buildAlternates } from "../../../../i18n/seo"; - -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { - const { locale } = await params; - return { - title: "EULA — cmux", - description: "End-User License Agreement for cmux", - alternates: buildAlternates(locale, "/eula"), - }; -} +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "EULA — cmux", + description: "End-User License Agreement for cmux", + alternates: { canonical: "https://cmux.com/eula" }, +}; export default function EulaPage() { return ( diff --git a/web/app/[locale]/(legal)/privacy-policy/page.tsx b/web/app/[locale]/(legal)/privacy-policy/page.tsx index 9d65de5586cb..b307d7abdd79 100644 --- a/web/app/[locale]/(legal)/privacy-policy/page.tsx +++ b/web/app/[locale]/(legal)/privacy-policy/page.tsx @@ -1,14 +1,11 @@ -import { buildAlternates } from "../../../../i18n/seo"; +import type { Metadata } from "next"; import { Link } from "../../../../i18n/navigation"; -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { - const { locale } = await params; - return { - title: "Privacy Policy — cmux", - description: "Privacy policy for cmux", - alternates: buildAlternates(locale, "/privacy-policy"), - }; -} +export const metadata: Metadata = { + title: "Privacy Policy — cmux", + description: "Privacy policy for cmux", + alternates: { canonical: "https://cmux.com/privacy-policy" }, +}; export default function PrivacyPolicyPage() { return ( diff --git a/web/app/[locale]/(legal)/terms-of-service/page.tsx b/web/app/[locale]/(legal)/terms-of-service/page.tsx index 3e1a2b7d5114..3b048eecfaab 100644 --- a/web/app/[locale]/(legal)/terms-of-service/page.tsx +++ b/web/app/[locale]/(legal)/terms-of-service/page.tsx @@ -1,13 +1,10 @@ -import { buildAlternates } from "../../../../i18n/seo"; - -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { - const { locale } = await params; - return { - title: "Terms of Service — cmux", - description: "Terms of service for cmux", - alternates: buildAlternates(locale, "/terms-of-service"), - }; -} +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Terms of Service — cmux", + description: "Terms of service for cmux", + alternates: { canonical: "https://cmux.com/terms-of-service" }, +}; export default function TermsOfServicePage() { return ( diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 7725e66ffcb4..4749347e8929 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -28,9 +28,22 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: "/eula", lastModified: "2026-03-18", changeFrequency: "yearly" as const, priority: 0.3 }, ]; + // Legal pages are English-only (not translated), so they only get one entry. + const englishOnly = new Set(["/privacy-policy", "/terms-of-service", "/eula"]); + const entries: MetadataRoute.Sitemap = []; for (const { path, lastModified, changeFrequency, priority } of paths) { + if (englishOnly.has(path)) { + entries.push({ + url: `${base}${path}`, + lastModified, + changeFrequency, + priority, + }); + continue; + } + const alternates: Record = {}; for (const locale of locales) { alternates[locale] = diff --git a/web/proxy.ts b/web/proxy.ts index 547c423bd211..f22fe38329a1 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -15,6 +15,17 @@ export default function middleware(request: NextRequest) { return NextResponse.redirect(url.toString(), 301); } + // Legal pages are English-only. Redirect localized variants to the English version. + const legalPages = ["/privacy-policy", "/terms-of-service", "/eula"]; + const { pathname } = request.nextUrl; + for (const page of legalPages) { + if (pathname.endsWith(page) && pathname !== page) { + const url = request.nextUrl.clone(); + url.pathname = page; + return NextResponse.redirect(url, 301); + } + } + return intlMiddleware(request); } From cc1aef4630ac4d878a9606960b84ce543aae0da9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:45:12 -0700 Subject: [PATCH 5/7] Fix legal page redirect to only match // paths endsWith matched too broadly (e.g. /docs/eula). Now only redirects when the path after the first segment is an exact legal page match. --- web/proxy.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/proxy.ts b/web/proxy.ts index f22fe38329a1..85e7967bd5e5 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -15,13 +15,15 @@ export default function middleware(request: NextRequest) { return NextResponse.redirect(url.toString(), 301); } - // Legal pages are English-only. Redirect localized variants to the English version. - const legalPages = ["/privacy-policy", "/terms-of-service", "/eula"]; + // Legal pages are English-only. Redirect //privacy-policy etc. to /privacy-policy. + const legalPages = new Set(["/privacy-policy", "/terms-of-service", "/eula"]); const { pathname } = request.nextUrl; - for (const page of legalPages) { - if (pathname.endsWith(page) && pathname !== page) { + const secondSlash = pathname.indexOf("/", 1); + if (secondSlash !== -1) { + const rest = pathname.slice(secondSlash); + if (legalPages.has(rest)) { const url = request.nextUrl.clone(); - url.pathname = page; + url.pathname = rest; return NextResponse.redirect(url, 301); } } From a5245ca6d6a708de234bba8afec8732cc574207f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 14:53:57 -0700 Subject: [PATCH 6/7] Skip next-intl for legal pages to prevent locale redirect loop Without this, a Japanese user hitting /privacy-policy could be redirected by next-intl to /ja/privacy-policy, which our middleware redirects back to /privacy-policy, creating a loop. --- web/proxy.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/proxy.ts b/web/proxy.ts index 85e7967bd5e5..7a17968b3d74 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -15,9 +15,13 @@ export default function middleware(request: NextRequest) { return NextResponse.redirect(url.toString(), 301); } - // Legal pages are English-only. Redirect //privacy-policy etc. to /privacy-policy. + // Legal pages are English-only. Redirect //legal-page to /legal-page, + // and skip next-intl for /legal-page so locale detection can't redirect back. const legalPages = new Set(["/privacy-policy", "/terms-of-service", "/eula"]); const { pathname } = request.nextUrl; + if (legalPages.has(pathname)) { + return NextResponse.next(); + } const secondSlash = pathname.indexOf("/", 1); if (secondSlash !== -1) { const rest = pathname.slice(secondSlash); From fe8c44e63bccbffee4d83f91d732d3b712e209af Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 26 Mar 2026 15:02:01 -0700 Subject: [PATCH 7/7] Rewrite legal pages to /en/ instead of NextResponse.next() Pages live under app/[locale]/, so skipping next-intl entirely would break route resolution. Rewrite to /en/privacy-policy etc. so Next.js can resolve the [locale] segment correctly. --- web/proxy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/proxy.ts b/web/proxy.ts index 7a17968b3d74..af503681a9dd 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -20,7 +20,9 @@ export default function middleware(request: NextRequest) { const legalPages = new Set(["/privacy-policy", "/terms-of-service", "/eula"]); const { pathname } = request.nextUrl; if (legalPages.has(pathname)) { - return NextResponse.next(); + const url = request.nextUrl.clone(); + url.pathname = `/en${pathname}`; + return NextResponse.rewrite(url); } const secondSlash = pathname.indexOf("/", 1); if (secondSlash !== -1) {