SEO & structured data
Every article in the feed already carries what a search engine wants — an SEO title, a description, a canonical URL, a cover image, and a structured byline. This guide maps those fields to the on-page tags that rank. The conceptual reasoning lives in SEO and JSON-LD; this is the practical “which field goes where.”
The fields that drive SEO
Section titled “The fields that drive SEO”| Field | Drives | Notes |
|---|---|---|
seoTitle | <title>, og:title | A string — falls back to title server-side, so it’s always set. |
seoDescription | <meta name="description">, og:description | null when none — omit the tag and let the engine derive one. |
canonicalUrl | <link rel="canonical">, og:url | Absolute, or null if the site has no URL set. See Custom domain & canonical. |
image | og:image, twitter:card, JSON-LD image | null when the article has no cover; use summary instead of summary_large_image. |
inLanguage | <html lang>, JSON-LD inLanguage | BCP-47 tag, e.g. "en". |
publishedAt / updatedAt | article:published_time / article:modified_time, JSON-LD datePublished / dateModified | ISO-8601 strings. |
author | JSON-LD author + sameAs | See JSON-LD below. |
tags | article:tag | Possibly empty. |
JSON-LD: the author kind decides the schema
Section titled “JSON-LD: the author kind decides the schema”The byline’s author.kind chooses the schema.org type:
"real"→ aPerson(withjobTitleandsameAswhen present). A real human wrote it."persona"→ anOrganizationbyline — a brand voice, never a fabricated human.
{ "@context": "https://schema.org", "@type": "Article", "headline": "…", "datePublished": "2026-01-01T…", "dateModified": "2026-01-02T…", "inLanguage": "en", "author": { "@type": "Person", "name": "…", "jobTitle": "…", "sameAs": ["…"] }}Per framework
Section titled “Per framework”Export generateMetadata on the article route and a app/sitemap.ts. Map seoTitle → title, seoDescription → description, canonicalUrl → alternates.canonical, and image → openGraph.images. Emit the JSON-LD as a <script type="application/ld+json"> in the page body. The complete, copy-pasteable recipe is in SEO metadata & sitemap.
articleMeta does all of this for you — return it from the route’s meta export and it produces <title>, description, Open Graph + Twitter cards, <link rel="canonical">, and the schema.org Article JSON-LD (choosing Person vs Organization from author.kind) straight from the article:
import { fetchArticle, articleMeta } from "@ghostwritr/react-router";import type { Route } from "./+types/blog.$slug";
export async function loader({ params }: Route.LoaderArgs) { const article = await fetchArticle({ siteId: process.env.GHOSTWRITR_SITE_ID!, slug: params.slug!, }); if (!article) throw new Response("Not found", { status: 404 }); return { article };}
export const meta: Route.MetaFunction = ({ data }) => data ? articleMeta(data.article, { siteName: "Acme", url: "https://acme.com/blog/" + data.article.slug }) : [];Pass url when your blog lives on its own domain; pass siteName to set og:site_name and the JSON-LD publisher. The full reference is Article meta.
The loader exposes every SEO field on entry.data (seoTitle, seoDescription, canonicalUrl, image, inLanguage, author, timestamps). Render them into <head> and build the JSON-LD in your layout. The full reference is SEO and JSON-LD.
What to reach for next
Section titled “What to reach for next”- The concept — why these fields and what “ranks” means here. See SEO and JSON-LD.
- Canonical policy — when your blog has its own domain. See Custom domain & canonical.
- The article shape — the full field reference. See The article shape.