Skip to content

Give a shared profile a card with the practitioner on it #144

Description

@davidtaing

TL;DR

  • The problem/p/<handle> is the link that actually gets shared; it is the page with a Share button, and a practitioner pasting their own profile into a client's Slack is the most valuable link this site has. It unfurls under Bluehex's name, with the site tagline and the site card. The practitioner appears nowhere on it. Verified on a running server: og:title is Bluehex.
  • The fix — give the route its own generated card: src/app/p/[handle]/opengraph-image.tsx returning an ImageResponse, carrying the practitioner's name, what they do, and the Verified badge where it is true. Not an openGraph override on the route — that was tried in Give a shared Bluehex link something to unfurl #143 and is worse than doing nothing; see below, because it is the thing most likely to be attempted first.
  • To decide — what an unverified profile's card says. The badge is the whole reason to build this and it is not true of every profile, so the card has two states and only one of them is the interesting one. That is a positioning call, not a layout one.
Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.

Follows #142, which put a static card on the whole site, and takes the half that ticket deferred.

What happens today

src/app/p/[handle]/page.tsx has a generateMetadata that sets title and nothing else. Metadata is inherited, so the route gets the root's openGraph block verbatim. Against a running server, /p/seed0002:

<title>          Toby Nakamura — Bluehex
og:title         Bluehex
og:description   Claude practitioners from the Code.Sydney community. Find one, hire one.
og:image         /opengraph-image.png        ← the site-wide card

The name reaches <title> and neither Open Graph field. The card that renders is the site's own — correct, on-brand, and about the directory rather than about the person whose page was shared.

This is not a regression. Before #142 there was no card at all. It is the gap that ticket left open on purpose.

Why the one-line fix does not exist

Read this before writing any code, because the obvious move is wrong and looks right. It was applied and reverted during the review of #143.

Adding an openGraph block to the route's generateMetadata does set og:title and og:description — and it drops og:image, og:image:*, og:site_name and og:type. The page ends up with a correct title and no card at all, which is worse than what it started with.

Next merges metadata shallowly. Per node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md, under "Overwriting fields": all openGraph fields from an earlier segment are replaced by the last segment to define them. The workaround the docs offer — pull the shared fields into a variable and spread them — does not rescue this case, because the root never declares images. src/app/opengraph-image.png is injected by the file convention, so there is nothing in the root's openGraph object to spread back in. The only remaining option is hardcoding the image path, which loses the content hash that the file convention exists to provide.

So the route cannot borrow the site card while overriding its text. It needs an image of its own, which is what this ticket is.

The reasoning is already a comment on generateMetadata (4632080) so the absent openGraph reads as a decision. Delete that comment when this lands.

What to build

src/app/p/[handle]/opengraph-image.tsx, default-exporting a function that returns an ImageResponse from next/og. It sits beside page.tsx and Next wires the tags automatically — same convention as the site card, one segment down.

It should carry the practitioner's name, headline, and the Verified badge when hasVerifiedBadge is true. Read the profile with getProfileByHandle from @/lib/directory, the same call page.tsx and generateMetadata already make — it reads through React's per-request memo, so it is not a third round trip.

Details that will bite

  • next/font does not work inside ImageResponse. The Funnel Display and Funnel Sans setup in src/app/layout.tsx is unavailable there. Per the file-convention docs, the pattern is readFile of a .ttf from disk at module scope, handed over in the fonts option — not a fetch of Google Fonts at build or request time. So a Funnel Display .ttf gets vendored into the repository. It is OFL, so that is permitted; say where it came from in the commit.
  • Per-profile alt text needs generateImageMetadata, not the alt export. export const alt = "..." is a constant and would give every practitioner in the directory the same alt text. generateImageMetadata receives params and returns an array whose entries carry alt, size, contentType and a required id. That is a second profile lookup — memoised, but worth knowing it is there.
  • The generated image is cached, and this card embeds a revocable claim. The docs say a generated image is statically optimised unless it uses request-time APIs or uncached data. This one reads Postgres. More important than the mechanics: the card shows the Verified badge, and verified is Bluehex-owned and revocable, as is status. A cached card is a pulled badge still being served — the exact failure src/app/page.tsx's revalidation comment is written about, now baked into an image on somebody else's servers. The tags named there (practitioner:<id>) are the contract; choosing the mechanism is Decide how cached pages are invalidated when the data changes #117. Do not invent a second scheme here.
  • pnpm build must stay green with no environment variables set. generateStaticParams returns [], so nothing is prerendered and nothing should query at build time — but this adds a second module that reads the database, so check it rather than assume it. mv .env.local aside and build; env -u alone passes for the wrong reason.
  • Twitter needs nothing extra. twitter:image falls back from the Open Graph image, verified in Give a shared Bluehex link something to unfurl #143's build output. Do not add a twitter-image.tsx.
  • 1200×630. Same as the site card. Keep type well clear of the edges — LinkedIn crops in some placements.

The design

There is prior thinking on this: the design canvas cut for #142 explored a badge-led direction (kept on its "Considered" page) and it was rejected for the site-wide card specifically, on the grounds that showing the badge on a card that is not about any particular practitioner is a claim Bluehex has not made. That objection disappears here. On a profile card the badge is a statement about exactly the person the card is about, which is the case it was drawn for.

Reuse the tokens in src/app/globals.css and the tick from src/components/credential-mark.tsx rather than redrawing either — the site card already does, and a second tick path is how the marks drifted the first time.

What has to be decided

The unverified state. hasVerifiedBadge is false for most profiles today, and the directory lists them deliberately — "anyone in the community can publish a profile" is the model. So the card has two states, and the second one is the awkward one:

  • Showing "Self-listed" is honest and reads as a demerit on the practitioner's own share.
  • Showing nothing where the badge would go is kinder and makes the badge's absence invisible, which weakens the thing the badge is for.
  • Showing only the name and headline for unverified profiles makes the two states different designs rather than one design with a slot.

This is a positioning question about what Bluehex is asserting on a practitioner's behalf, so it is @davidtaing's call rather than the implementer's. Nothing else here is blocked on it — the verified state can be built first.

Done when

  • Sharing /p/<handle> unfurls with a card naming that practitioner, in Slack and LinkedIn.
  • The badge appears when, and only when, hasVerifiedBadge is true, and the unverified state does whatever the decision above settles.
  • Alt text names the practitioner rather than being shared across every profile.
  • The comment on generateMetadata explaining the absent openGraph is removed, and the PR body's note in Give a shared Bluehex link something to unfurl #143 no longer applies.
  • pnpm build passes with no environment variables set.
  • pnpm lint, pnpm test and pnpm test:e2e pass.

Not in scope

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: uiReact routes, components, stylingenhancementNew feature or requesthitlNeeds a human (decision/design/review)size: MA weekend for someone new to this codebase

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions