You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(seo): add per-route metadata, robots, sitemap, and structured data - #10
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.
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):
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.
Absolute canonical on all 6 tested routes, each prefixed by the served origin.
All 8 keys present on every route (checked on all 8 routes, not just one).
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.
noindex, follow present in served HTML of /library, /login, /signup; absent on all indexable routes; robots.txt disallows only /auth/.
/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.
Raw HTML contains real rows, e.g. <p class="truncate text-sm font-medium">Come Together</p> on /, real playlist names on /library.
pnpm typecheck, lint, check:comments, test, build all exit 0. Build prerenders /, /search, /login, /signup, /library, and p1/p2/p3 (SSG).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every route served the same
<title>sunao</title>and the same meta description, androbots.txtandsitemap.xmlboth 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
page.tsxthat owns its metadata, plus a co-located*-client.tsxthat keeps the previous body verbatim.metadataandgenerateMetadatawork only in Server Components, and all six route files were client components.metadataBase, a%s | sunaotitle template, and the Open Graph and Twitter defaults.opengraph-image.tsxgenerates the share image andtwitter-image.tsxre-exports it.robots.tsandsitemap.tspublish the crawl files. The playlist route addsMusicPlaylistJSON-LD and prerenders the three seed playlists.scripts/check-seo.mjschecks the signals Lighthouse structurally cannot see.@vercel/analyticsand@vercel/speed-insightsare wired into the root layout, so the outcome metrics can be collected once enabled in the Vercel dashboard.scripts/check-comments.mjsno longer reads//inside a string literal as a comment. Without this fixpnpm check:commentsfails onmain, because the iTunes artwork URLs added ine63e2b3contain//.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_URLwhen 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.<title>and the meta description, which is what a search result uses. It does not hold forog:title,og:description, or anytwitter:*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:urlstays at the site root on those four routes, so it disagrees with their canonical.@vercel/analyticsand@vercel/speed-insightsscripts 404 when the app is served off Vercel. Benign on Vercel.robotsmeta tags (noindex, nofollowandnoindex). 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 seedinitialDataexists to protect. It callscreateBrowserClienton 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/signupare nownoindex, and/libraryis also gone from the sitemap.robots.txtdeliberately does not disallow those three, because a disallowed URL can never be crawled and itsnoindexwould 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:imageresolution,robots.txt,sitemap.xml, JSON-LD, and the robots and noindex conflict.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, andpnpm buildall pass. The build prerenders/,/search,/login,/signupand the three seed playlists.Come Together,Abbey Road, andThrillerstill appear in the initial HTML and a crawler still reads real text./libraryopens a playlist, a track plays, and main's queue toggle still works after the rebase.