Skip to content

feat(seo): add per-route metadata, robots, sitemap, and structured data - #10

Merged
paramcodes merged 7 commits into
mainfrom
feat/seo-discoverability
Oct 17, 2026
Merged

paramcodes merged 7 commits into
mainfrom
feat/seo-discoverability

Conversation

@paramcodes

@paramcodes paramcodes commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Why

Every route served the same <title>sunao</title> and the same meta description, and robots.txt and sitemap.xml both returned 404. Lighthouse's SEO audit still reported 100 through all of it, because its title and description checks assert only that the tags exist. This gives each route its own metadata and publishes the crawl files.

What changed

  • Each route is now a server page.tsx that owns its metadata, plus a co-located *-client.tsx that keeps the previous body verbatim. metadata and generateMetadata work only in Server Components, and all six route files were client components.
  • The root layout sets metadataBase, a %s | sunao title template, and the Open Graph and Twitter defaults. opengraph-image.tsx generates the share image and twitter-image.tsx re-exports it.
  • robots.ts and sitemap.ts publish the crawl files. The playlist route adds MusicPlaylist JSON-LD and prerenders the three seed playlists.
  • scripts/check-seo.mjs checks the signals Lighthouse structurally cannot see.
  • @vercel/analytics and @vercel/speed-insights are wired into the root layout, so the outcome metrics can be collected once enabled in the Vercel dashboard.
  • scripts/check-comments.mjs no longer reads // inside a string literal as a comment. Without this fix pnpm check:comments fails on main, because the iTunes artwork URLs added in e63e2b3 contain //.

Scope

Covers the six app routes and the crawl files. Leaves out the data layer on purpose: src/lib/queries.ts, src/lib/query-client.ts, and the (app) shell are untouched, so the hydration cost measured at 2.4s total blocking time is unchanged. No per-playlist share image, because the seed cover is a hex color and the artwork is a remote URL.

Two setup steps are not in this diff and are not code. Enable Speed Insights and Analytics in the Vercel dashboard, and verify the domain in Google Search Console before submitting the sitemap. Set NEXT_PUBLIC_SITE_URL when a custom domain exists, or the canonicals point at the Vercel host.

Known limitations

Recorded by an independent verifier against 0600d69, which did not write this code.

  • Per-route metadata holds for <title> and the meta description, which is what a search result uses. It does not hold for og:title, og:description, or any twitter:* field, which fall back to the root defaults on /search, /library, /login, and /signup. Only the playlist routes override them. A share card for those four routes therefore shows the site defaults.
  • og:url stays at the site root on those four routes, so it disagrees with their canonical.
  • The @vercel/analytics and @vercel/speed-insights scripts 404 when the app is served off Vercel. Benign on Vercel.
  • An unknown playlist id returns 404 with two robots meta tags (noindex, nofollow and noindex). Harmless.

Tradeoffs

The rejected alternative moved the pure fetchers into a server module and prefetched data with HydrationBoundary. It lost for three reasons. It rewrites the data path that the seed initialData exists to protect. It calls createBrowserClient on the server once Supabase env vars are set. And it does not reduce hydration cost either, since every route still hydrates its full client tree.

Blast Radius

Touches every route file and the root layout, so a mistake surfaces sitewide. The shell, player, queue, and store are untouched, so playback behavior is unchanged. /library, /login, and /signup are now noindex, and /library is also gone from the sitemap. robots.txt deliberately does not disallow those three, because a disallowed URL can never be crawled and its noindex would never be read.

Verification

  • node scripts/check-seo.mjs <base> reports 31 of 31 checks against a build from this branch. It covers title and description uniqueness, canonical, Open Graph and Twitter tags, og:image resolution, robots.txt, sitemap.xml, JSON-LD, and the robots and noindex conflict.
  • Independent verification returned PASS+NOTES, with all 11 claims tested against a real build and both the parent and head served side by side. The base build scores 11 of 31 on the same checker.
  • pnpm typecheck, pnpm lint, pnpm check:comments, pnpm test, and pnpm build all pass. The build prerenders /, /search, /login, /signup and the three seed playlists.
  • Server-rendered content is unchanged, so Come Together, Abbey Road, and Thriller still appear in the initial HTML and a crawler still reads real text.
  • Playwright confirms home renders, /library opens a playlist, a track plays, and main's queue toggle still works after the rebase.

@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sunao Ready Ready Preview Oct 17, 2026 12:09pm UTC

@paramcodes

Copy link
Copy Markdown
Owner Author

Independent verification of PR #10 (verdict: PASS+NOTES)

Head 0600d69, base 7a63171. Built and served head on :3310 with NEXT_PUBLIC_SITE_URL=http://localhost:3310 after rm -rf .next; built and served base on :3311 the same way. Author's check-seo.mjs passes 31/31 on head and fails 11/31 on base, so it is non-vacuous.

Claim evidence (raw, independent of the author script):

    1. Titles/descriptions unique and per-route. Literal: / "sunao, a Spotify-like music player" / 77ch; /search "Search | sunao" / 71ch; /library "Your Library | sunao" / 69ch; /login "Sign in | sunao" / 60ch; /signup "Sign up | sunao" / 64ch; p1 "Liked Songs | sunao" / 60ch; p2 "Late Night Drives | sunao" / 66ch; p3 "Workout | sunao" / 56ch. All 8 unique, all between 50 and 155. Base served identical <title>sunao</title> and identical "A Spotify-like music player" on all 8. One <title> per doc everywhere.
    1. Absolute canonical on all 6 tested routes, each prefixed by the served origin.
    1. All 8 keys present on every route (checked on all 8 routes, not just one).
    1. Every route's og:image is http://localhost:3310/opengraph-image?..., fetched: 200, image/png, 34699 bytes. twitter:image 200 image/png too.
  • 5/6. /robots.txt 200 (User-Agent: *, Allow: /, Sitemap: line) and /sitemap.xml 200 application/xml listing /, /search, /playlist/p1..p3 and none of /library, /login, /signup. Base returns 404 for both.
    1. noindex, follow present in served HTML of /library, /login, /signup; absent on all indexable routes; robots.txt disallows only /auth/.
    1. /playlist/p1 JSON-LD parses: {"@context":"https://schema.org","@type":"MusicPlaylist",...}. Same shape on p2/p3. No JSON-LD on other routes or on the unknown id.
    1. Raw HTML contains real rows, e.g. <p class="truncate text-sm font-medium">Come Together</p> on /, real playlist names on /library.
    1. pnpm typecheck, lint, check:comments, test, build all exit 0. Build prerenders /, /search, /login, /signup, /library, and p1/p2/p3 (SSG).
    1. Playwright: home shows "Good evening" and tracks; library opens "Late Night Drives" to /playlist/p2; clicking a track makes the player show Pause (playing). Queue toggle: aria-expanded true to false, panel width 288 to 40 to 288.

Adversarial probes: /playlist/does-not-exist returns 404 with title "Playlist not found | sunao", no canonical, no JSON-LD, two robots metas ("noindex, nofollow" and "noindex"). Timed-out route fetch tests confirm no missing sitemap entries and no noindex leak. check-comments.mjs flags a planted probe comment and passes after removal.

Notes the PR does not disclose:

  • og:url stays at the site root on /search, /library, /login, /signup (only playlists override it), so social og:url disagrees with those pages' canonicals.
  • og:title, og:description, and the twitter:* fields remain the root defaults on every non-playlist route, so the "per-route metadata" claim covers <title> and meta description only.
  • The added @vercel/analytics and @vercel/speed-insights scripts 404 on every page load when not served by Vercel (/_vercel/insights/script.js, /_vercel/speed-insights/script.js). Base had no such 404s. Benign on Vercel, noisy anywhere else.
  • Unknown playlist ids emit two robots meta tags; harmless.

Could not test: behavior on an actual Vercel deployment, and NEXT_PUBLIC_SITE_URL unset (defaults to https://sunao-omega.vercel.app), since only local origins were available. Servers killed by exact PID; the author's tree was not touched.

@paramcodes
paramcodes merged commit 66a6e7f into main Oct 17, 2026
3 checks passed
@paramcodes
paramcodes deleted the feat/seo-discoverability branch October 17, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant