Answer Engine Optimization for Next.js: Schema, llms.txt, and Proving It Shipped
A practical pass at making a Next.js site legible to AI answer engines: allowing the crawlers, publishing llms.txt, adding FAQPage and HowTo schema without hand-maintaining it, and asserting in the build output that all of it actually rendered.
Most SEO advice assumes a human reads your page from a results list. Increasingly they don't — they read a summary an LLM wrote about your page, and never visit. That shifts what matters: not just "can Google index this," but "can a model extract a correct, attributable fact from it."
That's what people mean by Answer Engine Optimization (AEO). The name is new; most of the mechanics aren't. Structured data, clean heading hierarchy, and explicit crawler permissions have mattered for years. What's new is that sloppiness now costs you differently — a model that can't parse your page doesn't rank you lower, it just paraphrases someone else.
I recently did a full pass on this portfolio. This post is what I changed, in order, plus the part almost nobody writes about: how to verify the markup actually shipped instead of trusting that it did.
Every example here is from this site's own source. The verification script at the end is the piece I'd keep if I could only keep one.
1) Let the AI Crawlers In — Explicitly
Answer engines use their own user agents, separate from Googlebot. If your robots.txt only speaks to *, you're relying on each crawler's default interpretation. Be explicit:
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-User
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: CCBot
Allow: /
Two of these are worth understanding rather than copying:
Google-Extendeddoesn't control indexing at all. It controls whether your content trains and grounds Gemini. Disallowing it does not remove you from Search.Applebot-Extendedis the same idea for Apple Intelligence.
So this is a decision, not a checkbox: you're opting into being used as source material. For a portfolio, being quotable is the goal. For a paywalled publication it's the opposite. Choose deliberately.
If you use next-sitemap, put the policy in config so it survives every build:
// next-sitemap.config.js
module.exports = {
siteUrl: 'https://www.yongsen.space',
generateRobotsTxt: true,
robotsTxtOptions: {
policies: [
{ userAgent: '*', allow: '/' },
{ userAgent: 'GPTBot', allow: '/' },
{ userAgent: 'ClaudeBot', allow: '/' },
{ userAgent: 'PerplexityBot', allow: '/' },
// ...
],
},
}
A trap I walked into:
next-sitemapoverwritespublic/robots.txton everypostbuild. I had been hand-editing that file, which meant my edits were discarded on each deploy. Worse, it was committed to git and listed in.gitignore— added before the ignore rule existed, so git kept tracking it.git rm --cached public/robots.txtfixed the tracking; moving the policy into config fixed the overwrite. If a file is generated, the generator is the only place worth editing.
2) Publish an llms.txt
llms.txt is an emerging convention: a plain-text, link-dense summary of your site at the root, aimed at LLM ingestion rather than rendering. No CSS, no JavaScript, no navigation chrome — just the facts and where to find more.
# Yong Sen Yeoh
> Full-stack developer and creative technologist. Builds performant,
> accessible, visually rich web apps combining React/Next.js with 3D
> and motion.
## Pages
- [Home](https://www.yongsen.space/): overview, skills, featured work
- [About](https://www.yongsen.space/about): background and experience
- [Projects](https://www.yongsen.space/projects): full project list
- [Blog](https://www.yongsen.space/blog): technical writing
## Full content index
- [Sitemap](https://www.yongsen.space/sitemap.xml) — every URL, current on each deploy
Being honest about status: this is a proposed convention, not a ratified standard, and adoption is uneven. It costs you fifteen minutes and one static file. That's a good trade on an unproven upside, but don't expect it to move numbers by itself.
Make it discoverable, since nothing links to a bare file at your root:
// app/layout.tsx
export const metadata: Metadata = {
alternates: {
types: {
'application/rss+xml': 'https://www.yongsen.space/feed.xml',
'text/plain': 'https://www.yongsen.space/llms.txt',
},
},
}
3) Fix Your Heading Hierarchy at the Root
This was the highest-value fix on my whole list, and it was a bug I'd been shipping for over a year without noticing.
My blog template rendered the post title as an <h1>. My MDX content files also opened with # Title. Result: two <h1> elements on every post, with the same text. A model parsing the page for "what is this document about" got a contradictory answer, and screen readers announced the title twice.
The tempting fix is editing all fifteen content files. The better fix is one place — the shared renderer every post and project routes through:
// components/MDXContent.tsx
/**
* Content files open with `# Title`, which duplicates the <h1> the page
* template already renders. Drop that first heading (only when it is the
* opening block).
*/
function stripLeadingH1(content: string) {
return content.replace(/^\s*#[^\n\S]+[^\n]*\n+/, '')
}
const markdownComponents: Components = {
// Page templates own the real <h1>. Any h1 left in the body would be a
// second h1 on the page, so demote it.
h1: ({ children }) => <h2>{children}</h2>,
// ...
}
export function MDXContent({ content }: MDXContentProps) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{stripLeadingH1(content)}
</ReactMarkdown>
)
}
Ten posts and five project pages, fixed in one function, and every file I write from now on is covered without me remembering a rule. Editing fifteen files would have been a bigger diff that left the next file broken.
The general lesson: when a defect appears in N files, look for the one place they all pass through.
4) Add FAQPage — But Keep It Visible
FAQPage is the highest-leverage schema for AEO, because a question-and-answer pair is exactly the shape an answer engine wants to lift.
There's one rule people skip: Google only honours FAQPage markup when the Q&A is actually visible on the page. Schema describing content a visitor can't see is a policy violation, not a shortcut.
So render both from the same data, and make drift structurally impossible:
// components/Faq.tsx
export function Faq({ items }: { items: FaqItem[] }) {
const structuredData = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
})),
}
return (
<section id="faq">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<dl>
{items.map((item) => (
<details key={item.question}>
<summary><dt>{item.question}</dt></summary>
<dd>{item.answer}</dd>
</details>
))}
</dl>
</section>
)
}
Two things worth stealing here. The schema and the visible markup read the same items array, so they cannot disagree — the failure mode where someone edits the copy and forgets the JSON-LD is designed out. And the accordion is a native <details>/<summary>: keyboard accessible, works with JavaScript disabled, zero dependencies.
Write answers that stand alone. "Yes, he's available" is useless out of context; "Yes. He is open to full-stack engineering and solutions architecture roles, as well as freelance projects, and can be reached at …" survives being quoted with no surrounding page.
5) Add HowTo Without Hand-Maintaining It
If you write tutorials, HowTo is the schema that matches. The naive approach is a howto: block in every post's frontmatter, duplicating headings you already wrote — which rots the moment you reorder a step.
My tutorials already number their steps as ## 1) Install the thing. That's structure I can read:
function extractSteps(content: string) {
const steps: { name: string; text: string }[] = []
let inFence = false
const lines = content.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (/^\s*```/.test(line)) {
inFence = !inFence // never treat a bash comment as a heading
continue
}
if (inFence) continue
const match = /^##\s+\d+\)\s+(.*\S)\s*$/.exec(line)
if (!match) continue
// ...first prose line under the heading becomes the step body
steps.push({ name: match[1].replace(/[`*_]/g, ''), text })
}
return steps
}
Then refuse to emit anything when the shape isn't there:
const steps = extractSteps(post.content)
// A one-step "how-to" is not a how-to.
if (steps.length < 2) return null
That last guard is what makes this safe to apply blanket-wide. Of ten posts, five have numbered steps and get HowTo; five don't and get nothing. My React explainers aren't procedures, so they stay silent without me maintaining a list. Trailing ## Troubleshooting and ## Final Thoughts sections are excluded automatically, because they aren't numbered.
The fence tracking matters more than it looks. My shell tutorials are full of lines like ## 3) restart nginx inside code blocks. Without the inFence toggle, those become phantom steps.
6) Give Your Identity Real Structure
Person schema with a name and a jobTitle is table stakes. The fields that actually answer questions are more specific:
{
'@type': 'Person',
name: 'Yong Sen Yeoh',
alternateName: ['Yong Sen', 'Yongsen'],
worksFor: { '@type': 'Organization', name: 'Whatif Solutions' },
hasOccupation: {
'@type': 'Occupation',
name: 'Full-Stack Engineer',
occupationalCategory: '15-1252.00', // O*NET: Software Developers
skills: ['Next.js', 'React', 'TypeScript', 'AWS', 'DevOps'],
occupationLocation: { '@type': 'City', name: 'Penang, Malaysia' },
},
seeks: {
'@type': 'Demand',
name: 'Full-stack engineering and solutions architecture roles',
},
}
hasOccupation with an occupationalCategory maps you onto a standard taxonomy instead of a free-text string. seeks states availability as data rather than prose buried in a contact section. alternateName covers the spellings people actually type.
Two more that are easy to get wrong:
Use the specific type. I had Article on blog posts where BlogPosting applies. Both validate; the narrower one carries more meaning.
mainEntityOfPage means this page. I had project pages pointing it at the live demo's external URL — asserting that the canonical page for my project was somebody else's domain. The demo belongs in sameAs:
mainEntityOfPage: { '@type': 'WebPage', '@id': url }, // this page
...(project.demo && { sameAs: [project.demo] }), // another representation
Schema that validates can still be wrong. A validator checks shape, not whether you meant it.
7) Stop Sending Contradictory Signals
Cheap to fix, easy to miss. My /chat route was noindex in its metadata — and listed in sitemap.xml. A sitemap is an invitation to index; noindex is a refusal. Sending both wastes crawl budget and muddies trust in the rest of your sitemap.
// next-sitemap.config.js
exclude: ['/chat'],
While auditing for this I also found a <a href="/"> in my header where a <Link> belonged — a full page reload on every logo click, and Next's own lint rule catches it. Which turned out to be worth knowing about, because npm run lint had never actually run on this repo: it prompted interactively for a config that didn't exist, so CI-less local runs silently did nothing. One three-line .eslintrc.json surfaced six real errors.
8) Verify It Shipped — Don't Trust It
Here's the step I'd keep above all the others.
Every JSON-LD block above is a string interpolated into a <script> tag by a React component that might not render, on a page that might not build, containing a field that might be undefined. A schema component that silently returns nothing looks exactly like a schema component that works.
Browser extensions and Google's Rich Results Test check one URL at a time, manually, after deploy. Instead, assert against your own build output across every page at once:
const fs = require('fs')
const path = require('path')
const walk = (d) =>
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
e.isDirectory()
? walk(path.join(d, e.name))
: e.name.endsWith('.html') ? [path.join(d, e.name)] : []
)
let problems = 0
for (const file of walk('.next/server/app')) {
const html = fs.readFileSync(file, 'utf8')
// Every JSON-LD block must be valid JSON.
const blocks = [
...html.matchAll(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g),
]
for (const [, body] of blocks) {
try {
JSON.parse(body.replace(/"/g, '"'))
} catch {
problems++
console.log('INVALID JSON-LD', file)
}
}
// Exactly one h1 per page.
const h1s = (html.match(/<h1/g) || []).length
if (h1s !== 1) {
problems++
console.log(`h1 count ${h1s}`, file)
}
}
console.log(problems ? `${problems} problem(s)` : 'all pages clean')
Run it after next build and you get, in one pass:
ok h1=1 /about Person, BreadcrumbList, FAQPage
ok h1=1 /resume ProfilePage, BreadcrumbList
ok h1=1 /blog Blog, BreadcrumbList, ItemList
ok h1=1 /blog/setup-ftp-user-ubuntu BlogPosting, HowTo, BreadcrumbList
ok h1=1 /blog/react-hooks-comparison BlogPosting, BreadcrumbList
That output is what caught the last two real bugs in my pass. /chat reported h1=0 — a standalone page whose only heading came from a section component that hardcoded <h2>. And printing the type list per page made an inconsistency obvious that I'd never have spotted by eye: my project detail pages had BreadcrumbList and my project index didn't.
Note the " replacement. React escapes quotes when serializing into dangerouslySetInnerHTML, so naive JSON.parse on the raw HTML fails on markup that's actually fine. I got a false positive before I noticed — worth knowing before you conclude your schema is broken.
You still want Google's Rich Results Test on the live URLs afterward. It enforces required-property rules a local parse doesn't see. But the local check is the one that runs every build, on every page, for free.
What I'm Not Claiming
I'm not going to show you a traffic graph and imply causation. This shipped days ago, AEO has no equivalent of rank tracking, and the honest position is that nobody has clean attribution for "an LLM cited my site" yet.
What I'll defend is narrower:
- The duplicate-
<h1>fix and themainEntityOfPagecorrection were real defects, worth fixing regardless of whether any model notices. FAQPage,HowTo, andProfilePageare documented, validated schema types that describe the content honestly. If answer engines use structured data — and every signal says they do — this is the format they read.- The verification script pays for itself immediately, because it found bugs I'd shipped and not seen.
Be suspicious of AEO advice with confident numbers behind it. The mechanics are worth doing because they're correct, accessible, and cheap. The measurement story isn't mature, and anyone telling you otherwise is selling something.
Quick Reference
| Change | Why it matters |
|---|---|
Explicit AI crawler policies in robots.txt | Removes ambiguity about permission |
llms.txt at root | Plain-text summary aimed at ingestion |
One <h1> per page, fixed in the shared renderer | Removes contradictory document signals |
FAQPage with visible Q&A from one data source | The shape answer engines lift directly |
HowTo derived from heading structure | Rich-result eligibility, nothing to maintain |
hasOccupation, seeks, alternateName on Person | Answers who-you-are and are-you-available |
BlogPosting over Article; mainEntityOfPage = this page | Precision; validating isn't the same as correct |
noindex routes excluded from the sitemap | Stops contradicting yourself |
Assert schema and h1 counts in build output | Catches what you'd otherwise deploy blind |
The pattern underneath all of it: make the page's meaning explicit and machine-readable, then prove the machine actually received it. The first half is what every AEO post covers. The second half is where the bugs live.
Post Details
Navigation
Related posts
Essential Security Practices to Protect Your Web Applications
Practical, easy-to-apply security improvements for any online project — from security headers, rate limiting, login protection, to safe file uploads and more.
Read more →Deploy Next.js on Ubuntu with Git, PM2, Nginx, and Certbot
Production-ready guide to deploy a Next.js app on Ubuntu using Git for code, PM2 for process management, Nginx as reverse proxy, and Certbot for HTTPS.
Read more →Building a Chatbot with RAG: How Retrieval Meets the LLM
A practical look at Retrieval-Augmented Generation: embeddings, vector search, and how to wire them to an LLM—plus how this portfolio implements the same pattern with Next.js, Supabase pgvector, and Hugging Face.
Read more →