Skip to content

SEO: metadata overrides, hidden posts, and structured data - #16

Merged
MaxDaten merged 17 commits into
mainfrom
claude/amazing-hypatia-f954dt
Oct 4, 2026
Merged

MaxDaten merged 17 commits into
mainfrom
claude/amazing-hypatia-f954dt

Conversation

@MaxDaten

@MaxDaten MaxDaten commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Adds editor-controlled SEO overrides in Sanity, respects hidden posts in search, and improves structured data across the site.

Summary

Editors can now override post titles, descriptions, and social images in Sanity's SEO group. Hidden posts remain reachable by link but are excluded from search results and sitemaps. Structured data is more complete: posts carry word count and author info, the /gems page has its own metadata and ItemList schema, and the author's /about page is correctly marked as a ProfilePage (not every page).

Key changes

  • SEO overrides: Posts respect seo.metaTitle, seo.metaDescription, and seo.ogImage from Sanity; fall back to post title/excerpt if not set
  • Hidden posts: Marked with noindex in robots meta and excluded from sitemap; still reachable by direct link
  • Post metadata: Word count and author ID now included in JSON-LD BlogPosting schema
  • Gems page: Gets its own title, description, and ItemList schema; cards have anchor IDs for internal links
  • /about/[authorId]: Only the site author's page is a ProfilePage; moved schema generation to a dedicated function
  • Heading anchors: New headingSlug() and assignHeadingAnchors() utilities for unique, stable heading IDs (handles repeated headings like GitHub)
  • Image improvements: Portable text images now include intrinsic dimensions and LQIP metadata; RSS feed renders images with captions
  • Internal links: New internalLinkPath() utility for consistent post and gem linking
  • Answer engines: robots.txt now explicitly names search crawlers (OAI-SearchBot, Claude-SearchBot, PerplexityBot, etc.)
  • Site href helper: siteHref() generates correct URLs across production domains; isExternalHref() replaces the removed HttpRegex
  • Home page copy: Updated hero subheadline (doubles as meta.description) to be more concise
  • Removed: Unused BlogPost type and HttpRegex utility

Testing

  • New unit tests for headingSlug(), postModifiedAt(), and internalLinkPath()
  • E2E tests verify hidden posts are noindex, /gems has correct metadata, posts carry tags/word count/author, and RSS images render
  • Sitemap respects seo.noIndex and dates posts by last edit
  • 404 page is marked noindex

https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS

claude added 17 commits October 4, 2026 17:51
The post query never projected the studio's seo object, so metaTitle,
metaDescription, ogImage and "No Index" had no effect. Posts now prefer the
overrides, and hidden or "No Index" posts emit noindex,follow. "No Index"
posts also drop out of the sitemap, RSS and llms.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
The sitemap used lastModified || date and never saw _updatedAt, while the
JSON-LD fell back to it, so lastmod stayed at the publish date. Both now
share postModifiedAt().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
The renderer read value.asset.url, but a portableImage holds its asset under
value.image as an unresolved reference, so every inline image was dropped
from the feed. Build the URL with urlFor(value.image) and keep the caption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
RSS and llms.txt rendered every internal link as /<slug>, so links to gems
pointed at pages that don't exist, and on the site /gems#<slug> had no
matching anchor. One internalLinkPath() now serves the page, RSS and
llms.txt, and each gem card carries its slug as id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
/gems returned no pageMetaTags, so it shipped the English home page's title,
description and Open Graph tags. It now has its own, plus ItemList and
BreadcrumbList JSON-LD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
On maxdaten.de, relative links to English pages (blog, gems, posts, feed)
went through a 308 to www.maxdaten.io, and the logo and "Start over" on
English pages hit www.maxdaten.io/ → /en. The legal pages were served on
both hosts.

- siteHref() links to canonicalUrl() on the production hosts (including
  the apex the prerender may run as) and stays relative elsewhere; header,
  footer, post cards, recent posts, RSS link and error pages use it
- the logo and "Start over" go to the home page in the page's language
- Card and Button treat links to the site's own hosts as internal, so they
  no longer open in a new tab (replaces the unused HttpRegex)
- www.maxdaten.io/{impressum,datenschutz} redirect to maxdaten.de

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
Both were built from url.origin, which at prerender is
VERCEL_PROJECT_PRODUCTION_URL and may be a redirecting host. They now use
the canonical English host, and prod-check asserts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
Inline images had no width or height, so the text below jumped as each one
loaded, and the LQIP prop was never passed. The post query now projects the
asset's dimensions and LQIP (none for transparent images, where the blur
would show through); the image sets width/height from the cropped size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
It took the legacy BlogPost type, so the post page padded it with
placeholders (hidden, keywords, relatedPosts, content) it never read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
- Open Graph article:published_time, modified_time, author and tags
- BlogPosting wordCount (counted, not estimated) and timeRequired, which
  were never set
- BlogPosting author from the post's Sanity author instead of hard-coded
- BlogPosting image at 1200px instead of the full-size cover upload
- ProfilePage only on /about/jloos instead of on every page

SITE_AUTHOR moves to its own module so the universal post load can use it
without bundling the Sanity client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
…views

robots.txt explicitly allows the search crawlers and user fetchers of
ChatGPT, Claude and Perplexity; training crawlers stay under the wildcard
(still allowed, as before). Pages emit max-image-preview:large.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
The prerendered /404 page had the default index,follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
lastBuildDate is the newest change to a post in the feed, so it moves only
when the feed's content does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
Heading ids were derived per heading in the browser: non-ASCII letters
vanished ("für" → "fr") and repeated headings shared an id. The post load
now assigns each heading a unique _anchor (-2, -3 for repeats) with
accented letters folded to their base letter. Existing anchors on public
posts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
meta.description repeated the whole hero text (~330 characters), which
search results cut off after about 155. It is now heroSummary(), the
paragraph the OG card already shows, so the two can't drift apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
- Title on / and /en: "Platform & Product Engineer, Hamburg |
  Jan-Philip Loos" (54 characters, no site-name suffix), also og:title
- The hero's first paragraph, and with it the meta description, OG card
  and llms.txt summary, now names the stack and where: "I build your
  product and the platform under it: Kubernetes, Nix, continuous
  delivery. …" (151/152 characters)
- The second paragraph drops its trailing "Hamburg", now in the first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNw4RCS11ZZRVjxynk9baS
@vercel

vercel Bot commented Oct 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
maxdaten-io Ready Ready Preview Oct 4, 2026 7:55pm UTC

@MaxDaten
MaxDaten merged commit 3e6b7ee into main Oct 4, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview — 51b03c0b Deployed Oct 4, 2026 by vercel[bot]
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.

2 participants