I asked a simple question before writing any code: should the guide pages use HowTo schema?
I remembered something — HowTo rich results don’t really show up on Google anymore. I wasn’t sure if that was still true or if I was thinking of an old update. Worth checking before building something that does nothing.
Turns out I was right, and the situation is worse than I remembered.
Why I Dropped HowTo Schema
Google deprecated HowTo rich results in September 2023. The schema.org type still exists, but Google Search no longer displays that result format. Adding valid HowTo markup therefore does not make a tutorial eligible for a Google HowTo rich result.
FAQ is different. Google did not announce the same full deprecation. In August 2023 it restricted FAQ rich results to well-known, authoritative government and health sites. A normal developer blog should not expect the expandable FAQ treatment.
This matters because the instinct is to add every schema type you can find. That creates more code to maintain without creating more value. Structured data should describe visible content accurately. It can make a page eligible for supported search features, but Google does not guarantee a rich result even when the markup validates.
Decision: Guide pages in this project use Article schema, not HowTo. FAQPage markup is generated only when the same questions and answers are visible on the page. I am not treating it as an AI-citation shortcut. Google explicitly says no special structured data is required for its generative AI features.
Sources: Google’s HowTo and FAQ change announcement, general structured-data guidelines, and Google’s generative AI search guidance.
One Component, Five Schema Types
Rather than building a separate component per schema type, I built one SchemaOrg.astro that switches behavior based on a type prop.
---
type Props = ArticleSchema | ReviewSchema | BreadcrumbSchema | OrganizationSchema | PersonSchema
const props = Astro.props
let schema: Record<string, any> = {}
if (props.type === 'Article') {
schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: props.title,
datePublished: props.publishedAt.toISOString(),
author: { '@type': 'Person', name: authorName, url: AUTHOR_URL },
publisher: { '@type': 'Organization', name: SITE_NAME },
// ...
}
}
// ... Review, BreadcrumbList, Organization, Person follow the same pattern
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
Each slug page imports it once and passes the relevant props:
<SchemaOrg
type="Review"
title={review.data.title}
description={review.data.description}
publishedAt={review.data.publishedAt}
itemReviewed={review.data.productName}
rating={review.data.rating}
/>
This is the same pattern as Callout.astro from earlier in the series — one component, a type prop, internal branching. It scales better than five separate files that all do nearly the same thing.
Person vs Organization — Use Both, Correctly
I had this backwards in my head before checking. The question was: does Google show an individual author’s name, or the site’s brand name, on search results?
The answer is both — they’re not competing, they serve different roles in the same schema object.
author should be Person — this is who wrote the content. Google can attach an individual’s name to a piece of content, which matters for E-E-A-T signals and how AI systems attribute information.
publisher should identify the organization publishing it. For Article markup, Google recommends including publisher information such as the organization name and logo where applicable.
author: { '@type': 'Person', name: authorName, url: AUTHOR_URL },
publisher: { '@type': 'Organization', name: SITE_NAME },
Both live inside the same Article or Review schema. No need for a separate page or component for Organization — it’s nested data, not a standalone entity in this project’s case.
Person does get its own standalone schema on the About page, since that page exists specifically to describe the author.
The Hardcoding Bug — and Then I Did It Again
While writing the SchemaOrg component, I had 'Steven Doan' hardcoded directly as a string inside the author field.
The fix: move author info into config.ts, the same file already holding SITE_NAME and SITE_URL from the meta tags work earlier in this series.
// src/config.ts
export const SITE_NAME = 'Astro Content Lab'
export const SITE_URL = 'https://astro-content-lab.vercel.app'
export const AUTHOR_NAME = 'Steven Doan'
export const AUTHOR_URL = 'https://doancongtuan.com'
export const AUTHOR_BIO = 'Freelance web developer. 10+ years building WordPress sites. Now learning Astro in public.'
One file. Anyone forking the project changes four lines, and every Person, Organization, Article, and Review schema across the entire site updates automatically.
This is the same idea as WordPress’s site settings page — one place where identity lives, every template reads from it instead of hardcoding it.
I fixed it. Felt good about it.
Then, two steps later, while wiring up BreadcrumbList across the four slug page types, I wrote this:
<SchemaOrg
type="BreadcrumbList"
items={[
{ name: 'Home', url: 'https://astro-content-lab.vercel.app/' },
{ name: 'Reviews', url: 'https://astro-content-lab.vercel.app/reviews/' },
{ name: review.data.title, url: Astro.url.href },
]}
/>
Same exact mistake. Hardcoded the production URL directly into JSX, four times, across four files — right after fixing the identical problem with the author’s name.
Fixed by importing SITE_URL into each slug page and interpolating it:
import { SITE_URL } from '../../config'
<SchemaOrg
type="BreadcrumbList"
items={[
{ name: 'Home', url: `${SITE_URL}/` },
{ name: 'Reviews', url: `${SITE_URL}/reviews/` },
{ name: review.data.title, url: Astro.url.href },
]}
/>
Why the Repeat Mistake Matters More Than the Fix
The interesting part isn’t the bug. Hardcoded strings are an easy, forgivable mistake. The interesting part is that it happened twice in the same session, on the same category of problem, right after explicitly identifying and fixing it the first time.
That’s not really a code problem. It’s a habit problem. Knowing the rule (“don’t hardcode site-specific values”) and actually applying it consistently across every new piece of code you write are two different skills. The first time, I was reactive — fixing something already wrong. The second time, I was writing new code and didn’t apply the same standard proactively.
For a project meant to be cloned and reused, this matters more than it would on a one-off client site. Every hardcoded value is a thing someone else has to find and fix manually, in a part of the codebase they probably won’t think to check.
The rule going forward: before writing any string that includes a site name, URL, or author name, ask whether it should come from config.ts instead. Not after writing it — before.
What’s Actually in Place Now
After Articles 17A and 17B, the relevant pages in astro-content-lab ship with:
- Meta description, canonical URL, robots tag
- Open Graph and Twitter Card meta tags, with per-page images
Articleschema for blog posts, guides, and comparisonsReviewschema with rating for review pagesBreadcrumbListschema for all four content typesPersonschema on the About pagePerson/Organizationnested correctly inside every Article and Review- Zero hardcoded site or author values — all of it traces back to one config file
None of this required a plugin. There is no SEO settings panel or admin UI, which is either clean or inconvenient depending on who edits the site. For this developer-owned project, a typed component and one config file are enough.
The next step is making those pages discoverable through a sitemap and a clear robots.txt file.