Files
supabase/apps/www/app/blog/BlogLayoutShell.tsx
claude[bot]andClaude 580af6e336 fix(www): stop blog tag and author breadcrumbs from becoming the page h1 (#49507)
<!-- ccr-slack-attribution -->
_Requested by **Pam Chia** · [Slack
thread](https://supabase.slack.com/archives/C07P3AU3J2D/p1787616624076769)_

**Before:** searching "supabase blog" on Google surfaces blog tag
sitelinks titled `Blog/Tags/Supabase` and `Blog/Tags/Community`, each
with the same snippet scraped from the footer newsletter form ("Get
product updates and news from Supabase").

**After:** those results use the route's real title, `Blog | Supabase`,
with a description specific to the tag, for example "Blog posts tagged
Supabase."

This change stops the visible breadcrumb on blog tag and author pages
from being the page's only `<h1>`, and gives tag and category pages
distinct meta descriptions.

## How this changes the Google result

The bad sitelinks come from two independent mechanisms, and each half of
this PR targets one of them:

**Titles.** Google prefers a page's `<h1>` over its `<title>` when the
two disagree, and on tag pages the only `<h1>` is the breadcrumb, whose
textContent is exactly `Blog/Tags/Supabase` (JSX strips the whitespace
between the children). Demoting the breadcrumb to a `<nav>` removes the
conflicting heading, so Google should fall back to the route's real
`<title>`:

- `Blog/Tags/Supabase` becomes `Blog | Supabase`
- `Blog/Tags/Community` becomes `Blog | Community`

**Snippets.** The "Get product updates and news from Supabase. Subscribe
..." text is not a meta description; it is Google's own synthesized
snippet, scraped from the footer newsletter form. Every tag and category
page shipped the byte-identical description "Latest news from the
Supabase team.", and Google discards a description duplicated across
many URLs. With a unique per-page description, Google has a usable
candidate again:

- tag rows should show `Blog posts tagged Supabase.` / `Blog posts
tagged Community.`
- category rows (`Blog | Engineering`, `Blog | Product`) already had
correct titles but the same scraped snippet; they should now show `Blog
posts in Engineering.` / `Blog posts in Product.`

Two caveats. Meta descriptions are suggestions, not directives, so
Google can still synthesize its own snippet; removing the
duplicate-description cause makes the supplied one much more likely to
win, but does not force it. And the replacement `<h1>` is the constant
sr-only `Supabase Blog` on every listing page: if Google again prefers
the h1 over the title, all sitelinks would read `Supabase Blog`. A
page-specific heading ("Posts tagged Supabase", "Posts by <author>")
would eliminate that residual risk and is a candidate follow-up.

Neither change appears in search results until Google recrawls and
reprocesses these URLs (see Additional context; the tag routes are
absent from the sitemap, which slows this). Requesting reindexing of a
few of these URLs in Search Console would speed it up.

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix (SEO / accessibility), `apps/www` only.

## What is the current behavior?

Two separate defects feed the same bad search result.

Google prefers a page's `<h1>` over its `<title>` when the two disagree,
and on tag pages the only `<h1>` is the breadcrumb.
`apps/www/app/blog/tags/[tag]/TagClient.tsx:21-27` wraps `Blog` / `/` /
`Tags` / `/` / `<tag>` in an `<h1>`; JSX strips the whitespace between
those children, so the element's textContent is exactly
`Blog/Tags/Supabase`. The route's own `<title>` is already fine
(`apps/www/app/blog/tags/[tag]/page.tsx:26`).
`apps/www/app/blog/authors/[author]/AuthorClient.tsx:55-60` has the
identical defect. Category pages escape it because
`apps/www/app/blog/BlogLayoutShell.tsx:19` counts `/blog/categories/` as
a listing route and renders the sr-only `<h1>Supabase Blog</h1>` at line
23, while `CategoryClient.tsx` renders no `<h1>` of its own.

Separately, every tag and category page shipped the byte-identical
description `Latest news from the Supabase team.`
(`apps/www/app/blog/tags/[tag]/page.tsx:27`,
`apps/www/app/blog/categories/[category]/page.tsx:23`). Google discards
a description reused verbatim across many pages and generates its own
snippet, in this case from the footer newsletter copy at
`apps/www/components/Footer/index.tsx:179`.

## What is the new behavior?

- `BlogLayoutShell.tsx` — `isListingRoute` now covers `/blog/tags/` and
`/blog/authors/` alongside `/blog/categories/`, via a
`LISTING_ROUTE_PREFIXES` constant. Those routes now render the same
sr-only `<h1>Supabase Blog</h1>` category pages already had.
- `TagClient.tsx` and `AuthorClient.tsx` — the breadcrumb is now a `<nav
aria-label="Breadcrumb">` instead of an `<h1>`. Every existing
className, the `/` separator spans and their `px-2` padding are
unchanged. The `h1` element selector in
`apps/www/styles/globals.css:176-179` applies `font-heading font-medium
tracking-normal`, so those three utilities move onto the `nav` to keep
the rendering byte-identical. No visual change is intended; only the
element and its accessible role change.
- `tags/[tag]/page.tsx` and `categories/[category]/page.tsx` —
`generateMetadata` now returns `Blog posts tagged ${label}.` and `Blog
posts in ${label}.`, matching the shape the author route already uses
(`apps/www/app/blog/authors/[author]/page.tsx:37`). Both use `startCase`
from `apps/www/lib/helpers.tsx:75-81` rather than `capitalize`, so
`launch-week` reads "Launch Week" and matches the filter chip label at
`apps/www/components/Blog/BlogFilters.tsx:56-57`. Title and description
use the same label.

## Additional context

`apps/www` is owned by `@supabase/marketing` per
`.github/CODEOWNERS:13`, so marketing should review this.

Recovery is not immediate: Google has to recrawl these routes before the
corrected titles and descriptions show up in search results.

Noted follow-ups, deliberately out of scope here:

- `/blog/tags/*` and `/blog/authors/*` are absent from the generated
sitemap (`apps/www/internals/generate-sitemap.mjs`), which slows
discovery and recrawl.
- The page bodies still pass a `capitalize`-derived label to `TagClient`
and `CategoryClient`, so the visible tag breadcrumb reads "Launch week"
while the title now reads "Launch Week". Aligning the visible label
would be a rendered-text change, so it is left for a separate PR.
- `openGraph` metadata on these routes is untouched and still carries
the generic copy.

### How it was tested

Prettier passes on the five changed files with the repo config, in both
the default and the `SORT_IMPORTS=false` mode CI uses. Full typecheck
and lint could not be run in this environment (no `node_modules`); the
changes are type-trivial — a `string[]`.`some()` predicate, a JSX tag
swap, and two template literals over an existing exported
`startCase(string): string` helper — and the diff parses clean under
`tsc` with module resolution disabled. Worth a reviewer eyeballing the
tag and author pages side by side against production to confirm the
breadcrumb renders identically.


---
_Generated by [Claude
Code](https://claude.ai/code/session_0164goAzGTkGnoxzCKagJ3Hw)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 14:44:58 +08:00

35 lines
1.1 KiB
TypeScript

'use client'
import { usePathname } from 'next/navigation'
import type PostTypes from 'types/post'
import BlogHero from './BlogHero'
import DefaultLayout from '@/components/Layouts/Default'
// Listing routes have no page-specific title of their own, so they share the
// generic screen-reader heading below. Without it the visible breadcrumb ends up
// as the only heading on the page, and search engines pick that up as the title.
const LISTING_ROUTE_PREFIXES = ['/blog/categories/', '/blog/tags/', '/blog/authors/']
export default function BlogLayoutShell({
featuredPost,
secondaryPosts,
children,
}: {
featuredPost: PostTypes | null
secondaryPosts: PostTypes[]
children: React.ReactNode
}) {
const pathname = usePathname()
const isListingRoute =
pathname === '/blog' || LISTING_ROUTE_PREFIXES.some((prefix) => pathname?.startsWith(prefix))
return (
<DefaultLayout>
{isListingRoute && <h1 className="sr-only">Supabase Blog</h1>}
<BlogHero featuredPost={featuredPost} secondaryPosts={secondaryPosts} />
{children}
</DefaultLayout>
)
}