Give a shared Bluehex link something to unfurl - #143
Conversation
Every link to this site rendered as a bare grey card. The root metadata set `openGraph` with a title, a description and a `siteName` and no image, and no `metadataBase`, so there was nothing for a crawler to draw and no absolute origin to resolve one against if there had been. The link that gets shared most is a profile — `/p/<handle>` has a Share button — and that page overrides only `title`, so it inherited the same empty card with somebody's name on it. Three things, and the first is the one that is easy to forget and then debug for an hour: `metadataBase` from `site.origin`, which is what a relative image path resolves against and what Next warns about at build time when it is absent. It reads a constant rather than an environment variable on purpose, so `next build` still passes with none set. Then the image itself, as the App Router file convention rather than an asset in `public/`: Next emits `og:image` with a content-hashed absolute URL plus the type, width and height tags, and `opengraph-image.alt.txt` beside it becomes `og:image:alt`. Hand-writing the `images` array would have left three tags to keep in sync by hand and an unhashed URL that caches badly against crawlers that have already fetched it. `twitter:image` falls back from the OpenGraph image, so no second `twitter-image.png` is needed — only `twitter.card`, without which X renders a 1200x630 card as a small square thumbnail. The card is the home page's own `h1` — "Claude Specialists." — with "Find one, hire one." in lime, on the site's tokens and in the two faces the site already loads. So the card is not a summary of the page, it is the page: same words, same face, same scale on arrival. "A Code.Sydney Company" is deliberately not on it; it stays in the header, where there is time to read it, rather than taking the second-largest slot on a card whose audience has not heard of Code.Sydney. Per-profile cards are the follow-up named in #142, and they are where the Verified badge belongs — on a site-wide card it would be a claim about a practitioner the card is not about. Closes #142
davidtaing
left a comment
There was a problem hiding this comment.
Automated review — Codex ran the pass, verified by Claude Code (Opus 5). Findings were checked against the source before posting.
Does what it says. metadataBase, the file-convention image and twitter.card all resolve correctly — confirmed in the built output and against a running server. Nothing blocking.
3 minor, 1 nit. Two are inline. Two could not be anchored to a diff line and are here:
- minor, pre-existing —
/p/[handle]renders<title>Toby Nakamura — Bluehex — Bluehex</title>.src/app/p/[handle]/page.tsx:78hardcodes the suffix that the roottitle.templatealready appends;/contactand/sign-inreturn their leaf alone and come out right. The fix is deleting— Bluehex. - nit —
src/app/opengraph-image.pnghas no source in the repo, so nobody can regenerate or amend it.
Most useful next: delete that suffix. It is one edit, it is in the route this PR is really for, and it ships here.
| @@ -29,6 +36,13 @@ export const metadata: Metadata = { | |||
| siteName: site.name, | |||
There was a problem hiding this comment.
minor — A shared profile unfurls without the practitioner on it.
Next shallow-merges metadata, so a child that sets only title inherits this whole openGraph object. /p/[handle]'s generateMetadata sets only title (src/app/p/[handle]/page.tsx:77-79), and the result on a running server at /p/seed0002 is:
og:title Bluehex
og:description Claude practitioners from the Code.Sydney community. Find one, hire one.
The name reaches <title> and neither OG field. Someone pasting their own profile into a client's Slack gets a card that does not say who it is about — and the PR body names that as the most valuable link on the site.
Not a defect in this diff, and #142 is right to defer the generated image to a follow-up. But the title and description are not the image. headline is already on the view model (src/lib/practitioners.ts:264), so:
return {
title: person ? person.name : "Profile",
openGraph: person ? { title: person.name, description: person.headline ?? undefined } : undefined,
};needs none of the ImageResponse machinery. Worth taking here or saying explicitly in the PR body that profiles still unfurl generically — it currently reads as though they are served.
There was a problem hiding this comment.
Deferred — and the fix this thread suggested is wrong, which is the part worth recording.
I applied it and checked what /p/seed0002 actually emitted. Naming openGraph here does override the title and description — and it also drops og:image, og:image:*, og:site_name and og:type. The profile page ends up with the right title and no card at all, which is worse than what it started with.
The docs are explicit, under "Overwriting fields" in node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md: metadata merges shallowly, and all openGraph fields from an earlier segment are replaced by the last segment to define them. The workaround they give — spread a shared object — does not rescue this case. The root never declares images, because opengraph-image.png is injected by the file convention, so there is nothing to spread back in. The remaining option is hardcoding the image path and losing the content hash, which is the thing this PR argues against.
So a profile keeps unfurling under the site's name. Fixing it properly means giving the route an image of its own, which is the per-profile card in #142 — where the image is being reconsidered anyway.
Reverted in 4632080, and the reasoning is now a comment on generateMetadata so the absent openGraph reads as a decision rather than an oversight. The other half of this thread — the PR body implying profiles are already served — is fixed in the body.
Leaving this open. It is a real gap and #142 should carry it, not a resolved thread.
There was a problem hiding this comment.
Cut as #144, which now owns this — it carries the trap in full, so the next person does not rediscover that an openGraph override drops the card.
`/p/<handle>` rendered `<title>Toby Nakamura — Bluehex — Bluehex</title>`. The root layout sets `title.template` to `%s — Bluehex`, and this route was the only one that also spelled the suffix out in the value it returned, so the template appended it a second time. `/contact` and `/sign-in` return their leaf alone and have always come out right. The early return for a missing profile replaces the ternary, which was doing two jobs at once and is what let the suffix hide in the truthy branch. Recorded in a comment at the same time: why this route does *not* declare its own `openGraph`, which reads like an omission and is not. Metadata merges shallowly, so naming `openGraph` here would replace every field the root set — and the share card goes with it, because the root never declares `images`. `opengraph-image.png` is injected by the file convention, so there is nothing to spread back in and the alternative is hardcoding the path and losing the content hash. A profile therefore unfurls under the site's name rather than the practitioner's. That is worse copy and the right trade against no card at all, and fixing it properly means giving the route an image of its own — the per-profile card in #142.
Deleting `metadataBase` fails nothing. Not the build, not the type check, not the linter — every `og:image` quietly becomes a `localhost` URL that unfurls as nothing, and the symptom surfaces weeks later in somebody else's feed. `twitter.card` is the same shape of failure: without it a 1200x630 image renders as a small square thumbnail, which looks deliberate. Asserted in `src/` rather than in `e2e/`, which is where checking what a served page emits would naturally belong. The `End-to-end tests` workflow is disabled by hand — that file's own header records an assertion drifting from the page for exactly that reason — so a guard there would gate nothing. `pnpm test` is what the required check runs. The cost is a stub for `next/font/google`, which the layout calls at module scope and which is a build-time transform rather than a runtime module. Both assertions were confirmed to fail with the lines they cover removed.
Review resolvedFixed — Fixed — the double site name in a profile's Deferred — a profile still unfurls under the site's name. This one is worth reading before it gets picked up again, because the obvious fix is wrong and I only found that by applying it. Declaring Not addressed —
One caveat that no amount of tooling fixes: I wrote this PR and I reviewed it. A human eye is still worth more than either pass. |
…145) Comment-only. No behaviour change. `src/app/p/[handle]/page.tsx` picked up a comment in #143 explaining why `generateMetadata` deliberately declares no `openGraph`. It ended by saying the proper fix "means giving this route an image of its own, which is the per-profile card in #142" — true when it was written, and no longer. #144 cut that ticket and then closed it. A per-profile card bakes the **Verified** badge into an image, and `verified` is Bluehex-owned and revocable: today a stale card is a generic card and harmless, but with the badge baked in a stale card is a pulled badge still being served out of Slack's and LinkedIn's caches. That makes #117 a correctness dependency rather than a scheduling one, and it is not a trade worth making for copy on a card while the directory is this small. So the comment points at #144 as the record instead of implying work nobody intends to do. It is also shorter: the part worth keeping is the trap — naming `openGraph` on this route replaces every field the root set and takes the share card with it, because the root never declares `images` and the file convention injects them. That is the mistake the next person makes, and it was made and reverted during #143's review. `pnpm lint` clean, `pnpm test` 305 passed. Nothing else touched.
Closes #142.
TL;DR
src/app/layout.tsxsetopenGraphwith a title, a description and asiteNameand no image, and nometadataBase— so there was nothing for a crawler to draw, and no absolute origin to resolve one against if there had been.metadataBasefromsite.origin, a staticsrc/app/opengraph-image.pngthrough the App Router file convention with its.alt.txtbeside it, andtwitter.card.h1. Look at the Files tab — GitHub renders the PNG.What the card says
Bluehex · Claude Specialists. / Find one, hire one. (lime) ·
bluehex.au. Nothing else.The headline is not a paraphrase of the page — it is
src/app/page.tsx's<h1 className="display-1">Claude Specialists.</h1>, verbatim, in Funnel Display at 116px on--color-page, with the call in--color-lime. Someone clicks the headline they just read and lands on it: same words, same face, same scale.Two deliberate omissions, both worth arguing with if you disagree:
The card inherits a vocabulary split rather than creating one:
CONTEXT.md's domain word is practitioner, and theh2under the hero says "Find a Claude practitioner." The hero has said Specialists since it shipped, and the card follows the hero. Worth settling, but on the hero rather than here.Why the file convention rather than
public/Next evaluates
src/app/opengraph-image.pngand emitsog:imageas a content-hashed absolute URL along with the type, width and height tags, and turnsopengraph-image.alt.txtintoog:image:alt. Hand-writing animagesarray inpublic/would have left three tags to keep in sync by hand and an unhashed URL that caches badly against every crawler that has already fetched it.twitter:imagefalls back from the OpenGraph image, verified in the build output below — so there is no secondtwitter-image.pngand no duplicated asset. Onlytwitter.cardis set, without which X renders a 1200×630 card as a small square thumbnail.metadataBasereadssite.origin, a constant, rather than an environment variable —next buildhas to keep passing with none set, and that is checked below.Verification
grepover the built/— every tag present, absolute againstbluehex.au, content-hashed:pnpm build— passes, no metadata warning.pnpm buildwith.env.localmoved aside — passes, so the no-Supabase build stays green.pnpm lint— clean.pnpm test— 303 passed.pnpm test:e2e— 24 passed with.env.localmoved aside, which is how CI runs it. With it in place and a local Supabase stack up,routes.spec.ts:59("an empty directory renders the invitation rather than nothing") fails on both projects, because the directory then has rows in it. That is the test meeting a populated local stack, not this change — but it is a real local-vs-CI trap and it caught me, so it is written down here.The image is 1200×630 and 38 KB, well under the 8 MB the build enforces. The headline size was measured rather than eyeballed: the widest line is "Find one, hire one." including the lime pill's padding, at 951px in Funnel Display and 957px in the
ui-sans-seriffallback, against 1056px of usable width.What this cannot prove yet
No crawler can reach
localhost, and this repository has no preview deployment, so the first real unfurl happens against production after merge. The markup is proved; the render in Slack and LinkedIn is not. Worth pasting the link into a DM once it ships — and note those services cache per URL, so a second look needs a query string.Not in this PR
/p/<handle>, the follow-up Give a shared Bluehex link something to unfurl #142 names. Be clear about what that leaves: a shared profile unfurls with this card and the site's name and tagline, not the practitioner's. OverridingopenGraphon that route is not the cheap fix it looks like — metadata merges shallowly, so naming it there replaces every field the root set and takes the image with it, since the root never declaresimagesand the file convention injects them. The route would need an image of its own — considered and declined in Give a shared profile a card with the practitioner on it #144, because baking a revocable badge into a cached image makes staleness a correctness problem rather than a cosmetic one. They wantImageResponse, which has two traps recorded in the issue:next/fontdoes not work inside it, and the profile route is ISR.robots.txt. Adjacent and separately worth doing.