000%

Updated

NOTES · SEO / GEO / AEO09 · 2026.10

Implement Public Content, Metadata, and Structured Data Together

Create the body, title, canonical URL, and structured data from copy returned by one public-query function. Compare request-time SSR pages and build-time archival pages in the same example.

§ 01SEO / GEO / AEO

Choose configuration locations and connect web, server, and infrastructure

The implementation delivers one operating notice as a public document with matching title, body, address, dates, and evidence. Crawlers can process its subject and canonical URL, and answers using web material can consult its facts and conditions. If the body has new hours but the summary and JSON-LD have old ones, the values conflict. The code below therefore derives each output from the same published revision.

Start by deciding when public pages change. Build HTML for infrequently changing guidance; read the current published revision on the server or update caches on publication events for frequently changing articles. Public pages and administration may share an application, but their query permissions and cache conditions remain separate.

The following is an example file layout for a Next.js site with a CMS. If the repository uses src, app and lib go beneath it. Choose function names such as getPublicGuide to suit the project.

app/ layout.tsx Shared interface and default metadata guides/[slug]/page.tsx Public query, body, and detail metadata robots.ts Crawling guidance sitemap.ts Public canonical URL list lib/ guides.ts Publication conditions and data queries identity-time.mjs Dates, public state, and canonical URLs site.ts Production domain settings next.config.ts Framework and deployment settings package.json Installed packages and build/start commands

Find build commands in package.json scripts. A directly operated Node.js server can build with next build and run with next start. Static exports are uploaded to static hosting. Managed platforms use project build and run settings; inspect the outputs and supported server features.

Connect the production domain with DNS and certificates, then allow public paths through the CDN and firewall. The server queries public articles and supplies HTML, metadata, and sitemap. Finally, check that publication events update each cache. Requests to the same URL from address connection through content delivery help locate the setting that changes the result.

Where to make changes
Body, title, links
Change location
page.tsx, body components, CMS copy
Result to check
Response HTML headings, paragraphs, a href
Publication conditions and dates
Change location
Server-query functions, database conditions, CMS publication
Result to check
Query results for public, not-yet-scheduled, and withdrawn articles
Canonical URL and search directives
Change location
generateMetadata, layout, robots, server headers
Result to check
Document canonicals and meta/HTTP search directives
URLs to discover
Change location
sitemap.ts, list pages, related-article queries
Result to check
Complete public list matches sitemap and internal links
Domain and certificate
Change location
DNS provider and hosting domain management
Result to check
Working DNS and HTTPS; redirect to canonical address
Blocking, proxying, caching
Change location
CDN, WAF, proxy, server-cache settings
Result to check
Public-body response, accurate status code, update timing
Execution and secrets
Change location
package.json, platform build, environment variables
Result to check
Production build, server execution, no secrets sent to browser
Failure checks
Change location
Hosting, CDN, application logs
Result to check
Per-path error rate, processing time, deployment version
§ 02SEO / GEO / AEO

Pin execution versions and example data

The example uses App Router on Next.js 16.3.8, React 19.2.5, TypeScript 5.9.3, and Node.js 22. Set cacheComponents to false in next.config.ts to use the dynamic settings below. A configuration enabling Cache Components needs its own use cache, cacheLife, and related settings.

The data contains one public notice and one draft from the fictional library. A fixed array makes query and output relationships traceable without a network or real database. In production, replace array reads with server-side database queries and apply the same publication conditions in them.

Save the code under the specified filenames in an empty directory. Create this chapter’s page and query files plus the later robots and sitemap files before running installation and build commands. Next.js can generate TypeScript configuration on first execution. Compare installed versions and settings before applying the example to an existing project.

The next three blocks are package.json, next.config.ts, and terminal commands, in order. Use Node.js 22 and preserve package-lock.json after installation. Use both specified versions and the lockfile to reproduce the environment.

{ "name": "seo-guide-sample", "private": true, "scripts": { "build": "next build --webpack", "start": "next start", "typecheck": "tsc --noEmit" }, "dependencies": { "next": "16.3.8", "react": "19.2.5", "react-dom": "19.2.5" }, "devDependencies": { "typescript": "5.9.3", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19" } }

import type { NextConfig } from 'next' const config: NextConfig = { cacheComponents: false } export default config

npm install npm run build npm run start -- --port 3027

npm install installs declared packages, build creates production output, and start runs it. The exercise is at http://localhost:3027; example.com in the code is a sample deployment canonical address. Set SITE_ORIGIN to the confirmed production domain.

§ 03SEO / GEO / AEO

Implement public queries and reuse results within a request

Put the canonical domain in lib/site.ts and publication conditions and queries in lib/guides.ts. React cache reuses results for calls with the same arguments during one server rendering request. Its lifetime differs from CDN or data caches retained across visits.

export const SITE_ORIGIN = 'https://example.com'

import { cache } from 'react' import { describeDocument } from './identity-time.mjs' import { SITE_ORIGIN } from './site' export type Guide = { id: string previousSlugs: string[] publicFrom: string | null slug: string title: string summary: string paragraphs: string[] sourceUrl: string | null validFrom: string | null validUntil: string | null status: 'draft' | 'published' | 'withdrawn' publishedAt: string | null contentModifiedAt: string | null deletedAt: string | null revision: number } // Fixed exercise data. Replace reads of this data with database queries in production. const guides: Guide[] = [ { id: 'hours-current', previousSlugs: ['library-hours'], publicFrom: '2026-10-01T00:00:00+09:00', slug: 'opening-hours', title: 'Library opening hours', summary: 'Sunday opening hours and closure dates.', paragraphs: [ 'Open Sundays from 10 a.m. to 5 p.m.', 'Closed on the first Sunday of each month and public holidays. This timetable applies from October 2026.', ], sourceUrl: '/policies/opening-hours', validFrom: '2026-10-01T00:00:00+09:00', validUntil: null, status: 'published', publishedAt: '2026-10-01', contentModifiedAt: '2026-10-23T09:00:00+09:00', deletedAt: null, revision: 2, }, { id: 'draft-example', previousSlugs: [], publicFrom: null, slug: 'draft-guide', title: 'Unpublished guide', summary: 'Draft', paragraphs: ['An exercise draft that must not appear in a public response.'], sourceUrl: null, validFrom: null, validUntil: null, status: 'draft', publishedAt: null, contentModifiedAt: null, deletedAt: null, revision: 1, }, ] function toPublic(g: Guide, asOf: string) { const info = describeDocument({ ...g, path: `/guides/${g.slug}`, eventDate: null }, asOf, SITE_ORIGIN) return info ? { ...g, info } : null } // Body and metadata in one request read the same time and published revision. const snapshot = cache(async () => { const asOf = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') return guides.map(g => toPublic(g, asOf)).filter(g => g !== null) }) export const getPublicGuide = cache(async (slug: string) => { return (await snapshot()).find(g => g.slug === slug || g.previousSlugs.includes(slug)) ?? null }) export const listPublicGuides = snapshot

lib/guides.ts reads fixed data and uses describeDocument to calculate ID, canonical URL, dates, public state, and applicability. React cache on snapshot reuses the result within one server request. getPublicGuide and listPublicGuides share it. This scope differs from long-lived database or CDN caches. React cache

sourceUrl is an evidence path that actually opens. Here it points to /policies/opening-hours, created below. validFrom and validUntil describe the timetable’s applicability, independently of publicFrom, which controls server publication.

First save identity-time.mjs from Documents and time as lib/identity-time.mjs. guides.ts actually calls describeDocument. snapshot fixes the request time once and returns public copy with its description. The old slug library-hours resolves to the same document, but page and generateMetadata call permanentRedirect to send a 308 to opening-hours. @id derives from the document ID hours-current and therefore survives address changes.

SITE_ORIGIN is also passed as describeDocument’s third argument. Canonical URLs and stable document @id values use the same configuration, so changing the production domain does not require separately editing the date module’s default sample address.

Sharing snapshot reduces both duplicate queries and inconsistent content. If a revision changes after reading the title and before reading the body, one response could mix two drafts. This example reuses the public list established within the request so title, body, and dates describe the same material. It aligns the meaning of the document delivered to search.

§ 04SEO / GEO / AEO

Output the same published revision on the SSR detail page

app/layout.tsx sets document language and the base URL. app/guides/[slug]/page.tsx queries public material matching the slug. force-dynamic selects request-time rendering in this example’s configuration with Cache Components disabled.

import type { Metadata } from 'next' import type { ReactNode } from 'react' import { SITE_ORIGIN } from '../lib/site' export const metadata: Metadata = { metadataBase: new URL(SITE_ORIGIN) } export default function Layout({ children }: { children: ReactNode }) { return <html lang="en"><body>{children}</body></html> }

import type { Metadata } from 'next' import { notFound, permanentRedirect } from 'next/navigation' import { getPublicGuide } from '../../../lib/guides' import { SITE_ORIGIN } from '../../../lib/site' import { formatContentDate } from '../../../lib/identity-time.mjs' export const dynamic = 'force-dynamic' type Props = { params: Promise<{ slug: string }> } export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params const guide = await getPublicGuide(slug) if (!guide) notFound() if (slug !== guide.slug) permanentRedirect(`/guides/${guide.slug}`) const canonical = guide.info.canonical return { title: guide.title, description: guide.summary, alternates: { canonical }, robots: { index: true, follow: true }, openGraph: { title: guide.title, description: guide.summary, url: canonical }, } } export default async function Page({ params }: Props) { const { slug } = await params const guide = await getPublicGuide(slug) if (!guide) notFound() if (slug !== guide.slug) permanentRedirect(`/guides/${guide.slug}`) const canonical = guide.info.canonical const structured = { '@context': 'https://schema.org', '@type': 'WebPage', '@id': guide.info.id, url: canonical, name: guide.title, description: guide.summary, ...(guide.info.datePublished ? { datePublished: guide.info.datePublished } : {}), ...(guide.sourceUrl ? { citation: new URL(guide.sourceUrl, SITE_ORIGIN).href } : {}), ...(guide.info.dateModified ? { dateModified: guide.info.dateModified } : {}), } const format = formatContentDate return <main><article data-revision={guide.revision}> <h1>{guide.title}</h1><p>{guide.summary}</p> {guide.publishedAt && <p data-date="datePublished"><span data-label>Published</span> <time dateTime={guide.publishedAt}> {format(guide.publishedAt)}</time></p>} {guide.contentModifiedAt && <p data-date="dateModified"><span data-label>Updated</span> <time dateTime={guide.contentModifiedAt}> {format(guide.contentModifiedAt)}</time></p>} <p>Applicability: {({ current: "Current", historical: "Historical", scheduled: "Scheduled", unknown: "Start date unknown" })[guide.info.applicability]}</p> {guide.validFrom && <p>Effective from <time dateTime={guide.validFrom}> {format(guide.validFrom)}</time></p>} {guide.validUntil && <p>Effective until <time dateTime={guide.validUntil}> {guide.validUntil}</time> (this instant excluded)</p>} {guide.paragraphs.map((text, i) => <p key={i}>{text}</p>)} {guide.sourceUrl && <p>Source: <a href={guide.sourceUrl}>Original library opening-hours notice</a></p>} <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structured).replace(/</g, '\\u003c'), }} /> </article></main> }

generateMetadata returns title, description, and canonical URL; Page outputs paragraphs from the same data. data-revision identifies the output version for comparison in the exercise. The guide uses WebPage. Services publishing articles should choose types such as Article with authors, dates, and images matching the actual material.

Call notFound when public material is absent. Loading boundaries or streaming may affect status codes, so check actual 404, noindex, and body responses. Track source-query exceptions as server errors rather than treating them as missing articles.

The first block is app/layout.tsx; the second is app/guides/[slug]/page.tsx. Pass the requested slug to the query and derive canonical, title, body, dates, and evidence link from the same guide. format displays verified dates in this language version’s format. dateTime preserves date-only or time-zone-bearing values at their original precision. Changing contentModifiedAt updates both the visible date and JSON-LD dateModified.

After running, /guides/opening-hours should display hours, first-Sunday closure, publication and modification dates, and evidence link. If dates are missing, inspect guide’s null values and time branches. If conditions are missing from the body despite JSON-LD being present, fix paragraphs. Check unknown slugs and draft-guide for 404 and noindex.

Publication and modification rows have data-date; each label is wrapped with data-label. Tests compare row meaning, time datetime, and the visible date separately. info.applicability is also visible to prevent mistaking a newly revised historical record for current guidance. formatContentDate displays date-only publishedAt values without time-zone shifts.

The map creating p elements turns library hours into visible HTML. generateMetadata separately supplies the subject and canonical URL. These roles mean title need not contain every hour and exception. Write a complete answer in the body and summarize it accurately in title and description. After edits, check that body and head refer to the same notice.

§ 05SEO / GEO / AEO

Make detail articles discoverable from a public list

The list uses the same publication conditions. Link in app/page.tsx creates links with actual detail URLs. Drafts must be absent from the list and return no public body even when requested directly.

import Link from 'next/link' import { listPublicGuides } from '../lib/guides' export const dynamic = 'force-dynamic' export default async function Home() { const guides = await listPublicGuides() return <main><h1>Library guides</h1><ul> {guides.map(g => <li key={g.slug}> <Link href={`/guides/${g.slug}`}>{g.title}</Link> </li>)} </ul></main> }

Map over guides to create Link elements with actual href values. Save as app/page.tsx, then click ‘Library opening hours’ on the home page to reach the detail. If an unpublished guide appears, check listPublicGuides. Test both list navigation and direct access to catch drafts hidden only on screen.

When the home page’s Link outputs an actual href, crawlers can discover the detail address from it. If articles open only through search input or click events, retrieval paths that do not perform those actions may never see the address. Providing both list links and sitemap creates discovery paths for new articles. Crawlable links

§ 06SEO / GEO / AEO

Create a static page for a fixed historical record

For this exercise’s single archive, create the fixed route app/archive/opening-2025/page.tsx. It outputs fixed file content without per-request queries, so the build can show it as static. /archive/missing has no route and uses the default 404 handling.

import type { Metadata } from 'next' import { SITE_ORIGIN } from '../../../lib/site' import { describeDocument, example } from '../../../lib/identity-time.mjs' // The archive publication and modification dates are unknown; do not output unverified dates. const info = describeDocument({ ...example, publishedAt: null, contentModifiedAt: null }, '2026-10-24T09:00:00+09:00', SITE_ORIGIN)! export const metadata: Metadata = { title: '2025 Library Opening-Hours Record', alternates: { canonical: info.canonical }, } export default function Archive() { const structured = { '@context': 'https://schema.org', '@type': 'WebPage', '@id': info.id, url: info.canonical, name: '2025 Library Opening-Hours Record' } return <main><article> <h1>2025 Library Opening-Hours Record</h1> <p>This fictional timetable applied from January 1 to December 31, 2025.</p> <p>At that time, Sunday hours were 11 a.m. to 4 p.m.</p> <p>Applicability: {info.applicability === 'historical' ? 'Historical' : 'Check validity period'}</p> <p>The original publication and modification dates of this material are unknown.</p> <a href="/guides/opening-hours">Current opening hours and closures</a> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structured).replace(/</g, '\\u003c'), }} /> </article></main> }

The file path determines the historical URL and metadata supplies a self-referencing canonical. The body preserves the 2025 period and links current guidance. publishedAt and contentModifiedAt are null, so publication and modification display branches are skipped.

Check that the build reports /archive/opening-2025 as static and /guides/[slug] as request-generated. Editing the fixed file requires a rebuild. For many archived documents, generateStaticParams can provide route lists. Separately test unregistered slugs, new documents, withdrawal, and regeneration in that setup. Generating static routes

This archive calls describeDocument using the Documents and time example with unknown publication and modification dates. Unknown dates are omitted from both body and JSON-LD. The end of applicability is known, so historical state and its own address remain. A fixed reference date is used for statically generating this already-expired record. Pages whose applicability may still change should be evaluated at request time or regenerated at the boundary.

§ 07SEO / GEO / AEO

Actually serve the document behind the evidence link

Save the following as app/policies/opening-hours/page.tsx. It provides hours, closure dates, and the effective date; the detail guide’s sourceUrl points here.

import type { Metadata } from 'next' import { SITE_ORIGIN } from '../../../lib/site' export const metadata: Metadata = { title: 'Original Library Opening-Hours Notice — Exercise Material', alternates: { canonical: new URL('/policies/opening-hours', SITE_ORIGIN).href }, } export default function Policy() { return <main><article><h1>Original Library Opening-Hours Notice — Exercise Material</h1> <p>Opening information for the fictional library effective from October 1, 2026.</p> <p>Sundays 10:00–17:00. Closed on the first Sunday of each month and public holidays.</p> <p>Notice published <time dateTime="2026-09-30">September 30, 2026</time></p> <a href="/guides/opening-hours">Return to visitor guidance</a> </article></main> }

The detail’s sourceUrl links to this route. Open it and compare hours, exceptions, and effective date. Even a 200 link is the wrong evidence if it concerns another year or branch. The evidence document also provides its own canonical and a return link.

§ 08SEO / GEO / AEO

Read streamed metadata output

Detail title, description, and canonical use the same data as the body. The title identifies the subject; the summary describes the audience and information provided. With Next.js metadata streaming, head information may be added after fetching completes. Receive the entire response before comparing per-document values. Next.js metadata

§ 09SEO / GEO / AEO

Handle paragraph arrays, Markdown, and media

guide.paragraphs is an array of body paragraphs. map converts each to p; key lets React distinguish list entries. React treats ordinary strings in braces as text. Put verified source-document addresses in sourceUrl and open the links to confirm they lead to the same material.

Markdown represents headings, lists, and links with simple symbols. HTML or Markdown CMSs use renderers to turn stored copy into HTML. Apply allowed-tag, attribute, and URL rules to external input to prevent script injection. Infinite-scroll lists need unique URLs and links to additional articles. Check that material existing only after ‘Load more’ has a discovery route for crawlers.

Give images relevant descriptions and suitable alt text. Do not put essential timetables or fees only in images; provide readable text or tables too. Specify screen-appropriate image sizes and width and height to reduce excess transfer and layout shifts. Add video metadata only to pages with actual video, and check playback URL, thumbnail, and accessibility.

§ 10SEO / GEO / AEO

Derive structured data from the body

The string ‘Kim Minsu, October 24’ alone may not tell whether the person is the author or subject, or whether the date is publication or an event. Structured data names relationships such as author, about, and datePublished. Google uses it to understand content and may use supported types for enhanced search displays. The role of structured data

‘Closed on the first Sunday’ is a fact answering the question; JSON-LD publisher and dateModified describe the publishing entity and update time. Deriving both from the same copy makes it possible to read who provided what notice and when. Adding WebPage to a guide does not create the closure condition, so output that sentence in p elements.

Choose a type matching the content, such as Article for articles and WebPage for usage guides. The following article-jsonld.mjs generates visible title, publication and modification dates, body image, and JSON-LD from one article object. Save it beside identity-time.mjs from the preceding chapter. Run node article-jsonld.mjs to print JSON, and read the complete HTML at /news/new-hours on the image-exercise server.

import { pathToFileURL } from 'node:url'; import { describeDocument, formatContentDate } from './identity-time.mjs'; export const article = { id: 'new-hours', path: '/news/new-hours', status: 'published', deletedAt: null, title: 'Changes to Library Sunday Opening Hours', summary: 'From October 2026, open Sundays 10–17. Closed on the first Sunday and public holidays.', publishedAt: '2026-09-30T09:00:00+09:00', publicFrom: '2026-09-30T09:00:00+09:00', contentModifiedAt: '2026-10-01T09:00:00+09:00', eventDate: null, validFrom: '2026-10-01T00:00:00+09:00', validUntil: null, }; export const escapeHtml = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); export function renderArticle(origin, image, now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')) { const identity = describeDocument(article, now, origin); if (!identity) throw new Error('Article is not public'); // Pass the same public image URL and actual converted dimensions in image. const imageUrl = new URL(image.path, origin).href; const structured = { '@context': 'https://schema.org', '@type': 'Article', '@id': identity.id, headline: article.title, mainEntityOfPage: identity.canonical, datePublished: identity.datePublished, dateModified: identity.dateModified, image: [imageUrl], author: { '@type': 'Organization', name: 'Fictional Library Editorial Team' }, publisher: { '@type': 'Organization', name: 'Fictional Library' }, }; const h = escapeHtml; const json = JSON.stringify(structured).replace(/</g, '\\u003c'); const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>${h(article.title)}</title><link rel="canonical" href="${h(identity.canonical)}"> <meta property="og:image" content="${h(imageUrl)}"> <script type="application/ld+json">${json}</script></head> <body><article><h1>${h(article.title)}</h1> <p>Published <time datetime="${h(identity.datePublished)}">${formatContentDate(identity.datePublished)}</time> · Updated <time datetime="${h(identity.dateModified)}">${formatContentDate(identity.dateModified)}</time></p> <p>${h(article.summary)}</p><figure> <img src="${h(imageUrl)}" width="${image.width}" height="${image.height}" style="max-width:100%;height:auto" alt="Exercise image used in the library guide"> <figcaption>An example for comparing image-delivery paths.</figcaption> </figure><p>Fictional Library Editorial Team</p></article></body></html>`; return { structured, html }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { console.log(JSON.stringify(renderArticle('https://example.com', { path: '/media/photo?w=1280', width: 1280, height: 800, }).structured, null, 2)); }

@context identifies the vocabulary, @type the document type, headline the article title, and mainEntityOfPage the representative page. datePublished is publication time, dateModified the substantive change time. Article represents an article, NewsArticle news, SocialMediaPosting a social post, and VideoObject video. Select types and properties matching the actual content.

Output JSON-LD in an application/ld+json script. Serialize included user input safely so it cannot inject a script-closing string. The Next.js JSON-LD example shows how to replace the serialized string’s < character with a Unicode escape. After output, both parse the JSON and compare it with the body.

<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structured).replace(/</g, '\\u003c') }} />

Check JSON-LD types and properties and compare title, author, and dates with the body. If search tools report errors, correct the property values and data types. Structured-data requirements

JSON.stringify turns an object into a JSON string; replace substitutes characters. The example’s Unicode escape \u003c represents the < character in JSON. dangerouslySetInnerHTML inserts a string directly into HTML in React; pass only safely serialized structured data here.

renderArticle verifies public status and dates through describeDocument and builds an absolute image URL. It uses that URL in the body img, og:image, and Article.image. escapeHtml handles special characters in HTML attributes and body; JSON’s < replacement prevents a script-closing string from being interpreted as HTML. The later JSX block applies the same serialization to a React page.

Passing the image server’s actual width and height to img reserves space before the file arrives. The body labels publication and modification separately; applicability is written in the article text. This specifies image and date relationships for search and gives readers evidence of the change. Google’s Article guidance recommends a crawlable representative image relevant to the article.

A 320px image may suit a small screen card, while a search representative image may need a larger file to show detail. This exercise uses the same 1280px derivative to demonstrate the relationship. In real articles, review image content and cropping and prepare ratios such as 1:1, 4:3, and 16:9 as needed. Do not default to scenes absent from the article or photos belonging to another article.

— FIN —