AEO on Next.js for marketing sites

Next.js gives you every AEO control Framer gives you, and you build each one yourself. Metadata, canonical tags, JSON-LD, a Markdown version of every page, and a log you can read. Budget a day of work on an App Router site that already exists. The Markdown endpoint is the piece Framer ships free and the one worth building first.

Oskar Mieta

Founder, Designer & Developer

Summarize with AI

Category

Platform

Reading time

6 min

Next.js gives you every AEO control Framer gives you, and you build each one yourself. Metadata, canonical tags, JSON-LD, a Markdown version of every page, and a log you can read. Budget a day of work on an App Router site that already exists. The Markdown endpoint is the piece Framer ships free and the one worth building first.

Every API below is written against Next.js 16.3.4, the current stable release, and the App Router. One file convention was renamed in version 16 and this article uses the new name.

Desses builds in Next.js and React, from $8,000 over about six weeks, and we're also a Framer Partner selling Framer development at the same starting price. We get paid whichever way this decision goes. Read the disqualification section first: most marketing sites that land here belong on Framer.

Server components render to HTML before anything reaches the browser, and HTML is all these crawlers read. The same checklist on Framer is article 08.

What Next.js hands you and what you write yourself

The job

Framer

Next.js

Full text in the initial HTML

Pre-rendered server-side, nothing to configure

Server components by default, and keeping critical content out of client-only components is on you

Title, description, canonical, OG

Per page and per CMS item in the UI

A metadata export or generateMetadata

JSON-LD

Custom code in the head with `{{Field \

json}}`

A script tag rendered by a server component

Markdown for agents

?md and Accept: text/markdown, free

A route handler you write

sitemap.xml and robots.txt

Auto-generated

app/sitemap.ts and app/robots.ts

Redirect when a slug changes

Doesn't happen, you add it by hand

redirects() in next.config, caught in code review

Server logs

Not available

Whatever your host exposes, or a drain to somewhere durable

Every one of these is ten minutes of work. All of them together are a system somebody has to own.

The metadata export covers title, description, canonical and OG

Set metadataBase once in the root layout and every relative URL resolves against it. Leave it out and a relative URL is a build error.

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://desses.co'),
  title: { default: 'Desses', template: '%s | Desses' },
}
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://desses.co'),
  title: { default: 'Desses', template: '%s | Desses' },
}
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://desses.co'),
  title: { default: 'Desses', template: '%s | Desses' },
}

Then per route, with generateMetadata. params is a promise and you await it.

// app/insights/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/insights/${slug}` },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.description,
      url: `/insights/${slug}`,
      publishedTime: post.publishedAt,
      images: [{ url: post.image, width: 1200, height: 630 }],
    },
  }
}
// app/insights/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/insights/${slug}` },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.description,
      url: `/insights/${slug}`,
      publishedTime: post.publishedAt,
      images: [{ url: post.image, width: 1200, height: 630 }],
    },
  }
}
// app/insights/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/insights/${slug}` },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.description,
      url: `/insights/${slug}`,
      publishedTime: post.publishedAt,
      images: [{ url: post.image, width: 1200, height: 630 }],
    },
  }
}

Keep themeColor, colorScheme and viewport out of that object. They've been deprecated inside metadata since Next.js 14. The canonical is the line people skip, and a site with tracking parameters and a paginated archive serves one article at four URLs.

JSON-LD goes in a server component, and it needs the sanitizer

// app/insights/[slug]/page.tsx
export default async function Page(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.authorName },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <article>{/* the page */}</article>
    </>
  )
}
// app/insights/[slug]/page.tsx
export default async function Page(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.authorName },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <article>{/* the page */}</article>
    </>
  )
}
// app/insights/[slug]/page.tsx
export default async function Page(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.description,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.authorName },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <article>{/* the page */}</article>
    </>
  )
}

Copy the .replace(/</g, '\\u003c') with the rest. It rewrites every < as its unicode escape, which stops a </script> arriving from your CMS from closing the tag early and turning the rest of that field into live markup. Next's own guide is blunt: JSON.stringify "does not sanitize malicious strings used in XSS injection". Any snippet without that line is one untrusted field away from a stored XSS.

It belongs in a server component because the script has to be in the bytes that come back from the first request. Build the same object in a useEffect and it appears after hydration, where no major AI crawler will see it.

The Markdown route handler is the highest-value file on the list

Framer returns a Markdown version of any page from an Accept: text/markdown header or ?md on the URL, with no configuration. Next.js has no equivalent until you write one, and it's about forty lines.

A page is always the leaf of its route subtree, so the handler lives one segment down. Route handlers take a params promise and return a plain Response, which is where the content type gets set.

// app/insights/[slug]/md/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return new Response('Not found', { status: 404 })

  const body = [
    '---',
    `title: ${JSON.stringify(post.title)}`,
    `description: ${JSON.stringify(post.description)}`,
    `published: ${post.publishedAt}`,
    `canonical: https://desses.co/insights/${slug}`,
    '---',
    '',
    post.markdown,
  ].join('\n')

  return new Response(body, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}
// app/insights/[slug]/md/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return new Response('Not found', { status: 404 })

  const body = [
    '---',
    `title: ${JSON.stringify(post.title)}`,
    `description: ${JSON.stringify(post.description)}`,
    `published: ${post.publishedAt}`,
    `canonical: https://desses.co/insights/${slug}`,
    '---',
    '',
    post.markdown,
  ].join('\n')

  return new Response(body, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}
// app/insights/[slug]/md/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return new Response('Not found', { status: 404 })

  const body = [
    '---',
    `title: ${JSON.stringify(post.title)}`,
    `description: ${JSON.stringify(post.description)}`,
    `published: ${post.publishedAt}`,
    `canonical: https://desses.co/insights/${slug}`,
    '---',
    '',
    post.markdown,
  ].join('\n')

  return new Response(body, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}

Then a rewrite, so the Markdown answers at the page's own URL. This is the file that changed name. middleware.ts was deprecated and renamed to proxy.ts in Next.js 16, the exported function is now proxy, and there's a codemod.

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl
  const accept = request.headers.get('accept') ?? ''
  const wantsMarkdown = searchParams.has('md') || accept.includes('text/markdown')

  if (!wantsMarkdown) return NextResponse.next()

  const url = request.nextUrl.clone()
  url.search = ''
  url.pathname = `${pathname.replace(/\/$/, '')}/md`
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: ['/insights/:slug'],
}
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl
  const accept = request.headers.get('accept') ?? ''
  const wantsMarkdown = searchParams.has('md') || accept.includes('text/markdown')

  if (!wantsMarkdown) return NextResponse.next()

  const url = request.nextUrl.clone()
  url.search = ''
  url.pathname = `${pathname.replace(/\/$/, '')}/md`
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: ['/insights/:slug'],
}
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl
  const accept = request.headers.get('accept') ?? ''
  const wantsMarkdown = searchParams.has('md') || accept.includes('text/markdown')

  if (!wantsMarkdown) return NextResponse.next()

  const url = request.nextUrl.clone()
  url.search = ''
  url.pathname = `${pathname.replace(/\/$/, '')}/md`
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: ['/insights/:slug'],
}

One trap worth the paragraph. Negotiating on Accept means one URL has two representations, and a CDN holding the cached HTML will serve it to the next client asking for Markdown, which is what Vary: Accept exists to prevent. The ?md path sidesteps it, since the query string is part of the cache key.

The real cost sits upstream. If your content is MDX or arrives from a CMS as Markdown, post.markdown already exists and this is a morning. If your pages are hand-built JSX, you're writing a serializer.

Whether AI crawlers ever ask for the Markdown is unmeasured, ours included, and llms.txt is the cautionary tale. The argument for this endpoint is that it lives on the page's own URL, where a crawler already is.

When a marketing site shouldn't be in Next.js

Most of the time, for most teams, and this is the section I'd want read out loud on the call.

Everything above is a file in a repository. Changing a meta description means a branch, a review and a deploy, and a marketer who wants to reword a hero on a Tuesday files a ticket. If nobody on the team can open proxy.ts without help, the Markdown endpoint breaks during an upgrade and nobody notices for months. Version 16 renamed that file. Somebody had to run the codemod.

Framer does the same jobs with none of that, editable by whoever is holding the laptop. If a marketing site is the whole scope and there's no engineer on the team, that's the answer, and article 09 compares the platforms properly.

Next.js earns its place on a short list of conditions. The marketing site sits next to a product surface with auth or dashboards. You need numbered pagination URLs or RSS, and Framer produces neither. Your content pipeline is genuinely custom, though Framer's Server API closed most of that gap in February 2026. Picking the heavier tool for a job the lighter one does costs you every month afterwards.

What we'd do on a Next.js marketing site

Curl a page with JavaScript off and read what comes back before touching anything else. Then metadataBase in the root layout, and generateMetadata on every dynamic route with an explicit canonical. Article and Organization JSON-LD from server components, sanitized, matching the visible text.

The Markdown route handler and the proxy rewrite, revalidated in the same webhook as the page. Then app/robots.ts and app/sitemap.ts, generated from code so the search-bot names sit under code review, and a log drain, because server logs are the only method that shows whether retrieval bots reach you at all.

Then Search Console's generative AI report and Bing's grounding queries, the only place any provider shows what was asked before you got cited.

If you want it built for you, our AI and custom development work covers it end to end.

If you're on Next.js and want to know what an agent reads, send me a URL. I'll fetch it with JavaScript off, send back what came out of the initial HTML, and tell you what the Markdown route would take on your setup. I'm around this week.

Questions people ask about this

Does Next.js get cited more than Framer?

No evidence either way, and no independent party has compared the platforms on retrieval. Both serve full text in the initial HTML when they're set up properly, and that's the part deciding whether a crawler can read you. The rest is content and entity strength.

What's the Next.js equivalent of Framer's `?md`?

A route handler that returns text/markdown, plus a proxy.ts rewrite when the request carries ?md or an Accept: text/markdown header. Code for both is above. Framer ships the same behaviour on optimised pages, no configuration.

Why does the JSON-LD snippet need that `.replace()`?

Because JSON.stringify doesn't sanitize XSS payloads, and you're passing its output to dangerouslySetInnerHTML. A </script> sitting in a CMS field closes the tag early and everything after it renders as markup. Escaping every < to \u003c makes that impossible. Next's docs carry the warning directly above the example, and community serializers like serialize-javascript do the same job.

Do I need llms.txt?

Probably not. Ahrefs found 97% of the valid llms.txt files across 137,000 domains got zero requests in May 2026, and that sample came from an SEO-tool-using population with adoption several times higher than anyone else measures. Google has said no three times. If you want one anyway it's a static file in public/, fifteen minutes.

Can I do all this on the Pages Router?

Yes, with different APIs. next/head for metadata, getStaticProps for the build, API routes for the Markdown endpoint. The metadata export and generateMetadata are App Router only.

Should the marketing site live in the same repo as the app?

Whoever needs to deploy a copy change decides. One repo means marketing waits on the app's release process. Two repos means two deployments and a design system to keep in sync. Split them when a non-engineer touches the marketing site weekly.

Questions people ask about this

Does Next.js get cited more than Framer?

What's the Next.js equivalent of Framer's `?md`?

Why does the JSON-LD snippet need that `.replace()`?

Do I need llms.txt?

Can I do all this on the Pages Router?

Should the marketing site live in the same repo as the app?