Yong Sen - Full-Stack Developer

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.

By Yong Sen Yeoh
September 1, 2026
12 min read

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-Extended doesn't control indexing at all. It controls whether your content trains and grounds Gemini. Disallowing it does not remove you from Search.
  • Applebot-Extended is 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-sitemap overwrites public/robots.txt on every postbuild. 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.txt fixed 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(/&quot;/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 &quot; 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 the mainEntityOfPage correction were real defects, worth fixing regardless of whether any model notices.
  • FAQPage, HowTo, and ProfilePage are 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

ChangeWhy it matters
Explicit AI crawler policies in robots.txtRemoves ambiguity about permission
llms.txt at rootPlain-text summary aimed at ingestion
One <h1> per page, fixed in the shared rendererRemoves contradictory document signals
FAQPage with visible Q&A from one data sourceThe shape answer engines lift directly
HowTo derived from heading structureRich-result eligibility, nothing to maintain
hasOccupation, seeks, alternateName on PersonAnswers who-you-are and are-you-available
BlogPosting over Article; mainEntityOfPage = this pagePrecision; validating isn't the same as correct
noindex routes excluded from the sitemapStops contradicting yourself
Assert schema and h1 counts in build outputCatches 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

September 1, 2026
12 min read
Tags
SEOAEONext.jsStructured DataSchema.orgDevOps