Skip to content

Give a shared Bluehex link something to unfurl - #143

Merged
davidtaing merged 3 commits into
mainfrom
feat/opengraph-image
Aug 30, 2026
Merged

Give a shared Bluehex link something to unfurl#143
davidtaing merged 3 commits into
mainfrom
feat/opengraph-image

Conversation

@davidtaing

@davidtaing davidtaing commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #142.

TL;DR

  • Every link to this site unfurled as a bare grey card. src/app/layout.tsx 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.
  • Three changes: metadataBase from site.origin, a static src/app/opengraph-image.png through the App Router file convention with its .alt.txt beside it, and twitter.card.
  • The card is the home page's own 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:

  • "A Code.Sydney Company" is not on the card, though it is in the site header on every page. On a card it would be the second-largest text, spending the best slot after the headline on a name the target audience — clients, not the community — has not heard of. It is an organisational fact with no time to land in a feed.
  • The Verified badge is not on the card either. It is the product, so this is the omission that deserves scrutiny. The argument for cutting it: a site-wide card that shows the badge makes a claim about a practitioner it is not about, and the directory lists unverified profiles too. The badge belongs on the per-profile card, which Give a shared Bluehex link something to unfurl #142 names as the follow-up and which this unblocks.

The card inherits a vocabulary split rather than creating one: CONTEXT.md's domain word is practitioner, and the h2 under 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.png and emits og:image as a content-hashed absolute URL along with the type, width and height tags, and turns opengraph-image.alt.txt into og:image:alt. Hand-writing an images array in public/ 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:image falls back from the OpenGraph image, verified in the build output below — so there is no second twitter-image.png and no duplicated asset. Only twitter.card is set, without which X renders a 1200×630 card as a small square thumbnail.

metadataBase reads site.origin, a constant, rather than an environment variable — next build has to keep passing with none set, and that is checked below.

Verification

grep over the built / — every tag present, absolute against bluehex.au, content-hashed:

og:image        https://bluehex.au/opengraph-image.png?opengraph-image.2lmftz03zquy0.png
og:image:type   image/png
og:image:width  1200
og:image:height 630
og:image:alt    Bluehex — Claude Specialists. Find one, hire one.
twitter:card    summary_large_image
twitter:image   https://bluehex.au/opengraph-image.png?opengraph-image.2lmftz03zquy0.png
  • pnpm build — passes, no metadata warning.
  • pnpm build with .env.local moved aside — passes, so the no-Supabase build stays green.
  • pnpm lint — clean.
  • pnpm test — 303 passed.
  • pnpm test:e2e — 24 passed with .env.local moved 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-serif fallback, 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

  • Per-profile cards for /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. Overriding openGraph on 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 declares images and 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 want ImageResponse, which has two traps recorded in the issue: next/font does not work inside it, and the profile route is ISR.
  • A sitemap or robots.txt. Adjacent and separately worth doing.

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 davidtaing left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:78 hardcodes the suffix that the root title.template already appends; /contact and /sign-in return their leaf alone and come out right. The fix is deleting — Bluehex.
  • nitsrc/app/opengraph-image.png has 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.

Comment thread src/app/layout.tsx
@@ -29,6 +36,13 @@ export const metadata: Metadata = {
siteName: site.name,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/app/layout.tsx
`/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.
@davidtaing

Copy link
Copy Markdown
Collaborator Author

Review resolved

Fixed — twitter.card and metadataBase are now guarded (cfa3bb6). src/app/metadata.test.ts, under src/ so the required check runs it; e2e/ would have been the natural home and gates nothing, since that workflow is off. Both assertions were confirmed to fail with the lines they cover removed.

Fixed — the double site name in a profile's <title> (4632080). /p/<handle> rendered Toby Nakamura — Bluehex — Bluehex: it was the only route that spelled out the suffix that title.template already appends.

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 openGraph in the route does set the title and description — and drops og:image, og:site_name, og:type and every og:image:* with it, leaving the page with a correct title and no card. Metadata merges shallowly, and the docs' spread-a-shared-object workaround does not help: the root never declares images, the file convention injects them, so there is nothing to spread. The route needs its own image, which is the per-profile card in #142. The thread stays open and the PR body now says plainly what a profile unfurls as.

Not addressed — opengraph-image.png has no source in the repo. Raised as a nit and left as one. The card was drawn on a design canvas and rendered headless; committing that script would put a Playwright-driven build step in the tree to regenerate one asset, and it is not obvious that is the right trade. Better as its own decision than smuggled in here.

pnpm lint clean, pnpm test 305 passed, pnpm build green.

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.

@davidtaing
davidtaing marked this pull request as ready for review August 30, 2026 01:51
@davidtaing
davidtaing merged commit c340d14 into main Aug 30, 2026
4 checks passed
@davidtaing
davidtaing deleted the feat/opengraph-image branch August 30, 2026 01:54
davidtaing added a commit that referenced this pull request Aug 30, 2026
…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.
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.

Give a shared Bluehex link something to unfurl

1 participant