WB × The Multiplier / Web & development
Checklist
The Next.js 15 SEO Checklist I Wish I Had Followed on My Own Site
Seven of my 16 public pages shared one title because they were client components. This is the list that fixed it, with what Next 16 changes.
By Weston BeckwithUpdated 11 min read

As of September 2026, a Next.js 15 site is ready for search when every route carries its own server-side metadata and a canonical URL. Add a generated robots.txt and sitemap.xml, an Open Graph card, a JSON-LD graph, sized images, and a build step that refuses to deploy when any of those is missing. That is the whole checklist. The rest of this piece is how each item works in the App Router, and where wbxcreative.com was failing on the morning of September 22, 2026.
The site runs Next.js 15.5.26 as of this writing. Next.js 16.3.6 is current, and Next 15 leaves maintenance on October 21, 2026, so the last item covers what changes when I move. Every check below comes from code in the repo today, not a tutorial, and the case study of that rebuild has the before-and-after.
How to think about it
Next.js does not do SEO. It gives you a Metadata API and a handful of file conventions, and it will happily build a site that uses none of them. The two failure modes I see are a page that renders perfectly with no metadata at all, and metadata that contradicts itself: a canonical on one host, a sitemap on another, a noindex behind a robots disallow.
Search engines read the prerendered HTML, not your JSX. After next build, every static route sits as an HTML file under .next/server/app. Check the list against those files, or let a script do it, which is item 11.
1. Put the site's identity in one file
Name, legal name, phone, email, city, logo paths, social profiles, the founder's bio URL, the public routes, and the routes that must never be indexed. On my site that is lib/site.ts, and metadata, schema, the sitemap, robots, and the OG cards all import from it. Change the phone number there and the schema follows; add a route and the sitemap follows. The footer still carries its own copy of the phone number, which is the honest footnote to "single source": it is only single once every consumer reads from it.
2. Set the root layout metadata once
The root layout exports metadataBase, a default title and a template, the description, authors pointing at the bio page, the Open Graph site name and locale, the Twitter card type, and a robots block. Two rules from the Next docs matter here. Relative canonicals and OG image paths resolve against metadataBase, and without it any relative URL field is a build error. And the title template applies only to child segments, so the homepage uses title: { absolute: SITE_TITLE } to escape it.
// app/layout.tsx (trimmed)
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: { default: SITE_TITLE, template: `%s | ${SITE_NAME}` },
description: SITE_DESCRIPTION,
authors: [{ name: PERSON.name, url: `${SITE_URL}${PERSON.path}` }],
openGraph: { type: "website", siteName: SITE_NAME, locale: "en_US" },
twitter: { card: "summary_large_image" },
robots: { index: true, follow: true },
};
3. Why do client pages lose their metadata?
Because export const metadata and generateMetadata only work in server files. A page.tsx that starts with "use client" cannot export metadata, and Next does not warn you. It falls back to the nearest layout. That is how seven of my 16 public routes, Home, About, Contact, Pricing, Specials, and Portfolio among them, ended up sharing the root title and description, and why a site: search that morning surfaced only the About page and a subdomain.
The fix is mechanical and boring, which is why it is worth doing first. Every interactive page becomes two files: a server page.tsx that exports metadata and renders a client component, and the client component that holds the state, animations, and forms.
// app/contact/page.tsx (server)
import type { Metadata } from "next";
import ContactClient from "./ContactClient";
export const metadata: Metadata = {
title: "Contact: Start a Branding, Web, or Systems Project",
description: "Tell Weston Beckwith what you are building. ...",
alternates: { canonical: "/contact" },
};
export default function Page() {
return <ContactClient />;
}
Eight pages went through that split that day.
4. Give every route a canonical
alternates.canonical on each page, relative, resolved against metadataBase. Dynamic routes build it in generateMetadata from the slug. Before the rebuild the site had none, which leaves Google to pick between www and bare, trailing slash and not, and whatever query strings arrive from ads. My gate checks that the emitted canonical equals the site URL plus the route path on every indexable page.
5. Generate robots.txt and sitemap.xml from code
app/robots.ts and app/sitemap.ts are the file conventions. Mine are short: robots allows everything, disallows the NOINDEX_ROUTES list, and points at /sitemap.xml. The sitemap is the static route list, the portfolio categories, the blog index and pillar pages once there are posts, and the published posts themselves, and it sets lastModified only on posts, which carry a real date in frontmatter.
// app/sitemap.ts (the only lastModified in the file)
const posts = published.map((p) => ({
url: `${SITE_URL}${postPath(p.slug)}`,
lastModified: p.dateModified ?? p.datePublished,
}));
That restraint is deliberate. Google's sitemap guidance says it uses lastmod only when the value is consistently and verifiably accurate, and it ignores changefreq and priority entirely. Google's cap is 50,000 URLs or 50 MB per sitemap file; a studio site is nowhere near either.
6. Does a robots.txt disallow replace noindex?
No, and the two interfere. Google's documentation is explicit: a noindex meta tag or X-Robots-Tag header only works if the crawler can fetch the page, so a URL that is disallowed in robots.txt can stay in the index with no snippet. Use noindex for pages you want out of results and disallow only for paths you never want crawled.
On my site the payment-instructions and post-submit routes (/payment, /specials/intake, /specials/thank-you) set robots: { index: false } in their metadata. /admin and /api get X-Robots-Tag: noindex, nofollow from the headers block in vercel.json, because an API route has no HTML head. I had this wrong on launch day: the first robots.ts disallowed the utility routes too, which would have hidden their noindex from the crawler. The NOINDEX_ROUTES list now holds only /admin and /api/, and the utility pages rely on their meta tag.
7. Ship an Open Graph card from next/og
app/opengraph-image.tsx is a default card for every route that does not bring its own: a 1200 by 630 PNG built with ImageResponse, the white logo read from public/ and inlined as a data URL, one line of positioning copy, and my name and city. Blog posts get their own at app/blog/[slug]/opengraph-image.tsx, which shrinks the font as the title grows. Before the rebuild there was no og:image and the Twitter card was summary, so a shared link showed a text stub.
Two details from the file convention docs: the build fails if a static OG image is over 8 MB or a Twitter image over 5 MB, and generated cards are statically optimized at build unless they touch request-time APIs, so the card renders once. The docs also suggest reading a static asset like the logo once at module scope; mine reads it inside the function, which costs nothing at build but is on the tidy-up list.
8. Emit one JSON-LD graph per page
The root layout emits a graph with three nodes that never change: the Organization (typed as both Organization and ProfessionalService), the founder as a Person, and the WebSite. Each has a stable @id (/#organization, /#founder, /#website), so pages can reference them without repeating them. Pages add their own nodes: WebPage or ProfilePage, a BreadcrumbList, FAQPage where there are questions, and Article on posts.
The Article node carries what Google's article guidance recommends: headline, datePublished and dateModified, an image when the post has one, and an author with a name and a url that points at a real profile page. On this site the author is { "@id": "…/#founder" }, which resolves to my bio page through the root graph, and that page is marked up as a ProfilePage, which is what Google asks for when the author URL is internal. The component that writes the script tag escapes < so no string can ever close it early.
9. Size every image with next/image
Heroes use fill, sizes="100vw", and priority. Grid tiles use fill inside an aspect-square box, loading="lazy", and a sizes string that matches the grid's breakpoints. Without sizes, Next assumes the image is 100vw and emits a 1x/2x srcset; with it, the srcset is built from deviceSizes and imageSizes and the browser picks the width it needs. next.config.ts sets formats to AVIF then WebP, and the source files were capped at 1,600 px with sharp, which is its own story.
10. One real H1, and the name on the homepage
Before the rebuild the homepage H1 was an animated word cycle and my name appeared nowhere on the page, so a search engine could not tell who the studio was. The rule now is one H1 per indexable page, written as a sentence a person would search for. The gate fails a page with zero H1s and warns on more than one.
11. Gate the build
scripts/gate.mjs runs after next build as part of npm run build, so it runs on Vercel too. It walks .next/server/app and fails the build on: thin pages, placeholder text, orphan pages, broken internal links or images, a missing title, description, canonical, or og:image, duplicate titles, JSON-LD that does not parse or lacks the Organization node, a noindex page listed in the sitemap, or a missing robots.txt or sitemap.xml. Near-duplicate pages and missing alt text are warnings. There is no bypass flag on purpose. The first green run covered 23 pages with zero failures.
12. What does Next 16 change for SEO?
The site is still on 15.5.26, so this is the part I have read but not yet run. From the upgrade guide and release notes:
| Change in 16 | What it touches here |
|---|---|
Turbopack is the default for next build; a custom webpack config fails the build unless you pass --webpack | next.config.ts has a webpack rule for .pdf, .ai, and .eps assets that needs porting to turbopack.rules |
middleware.ts becomes proxy.ts (Node runtime only) | The edge gate that guards /admin |
params arrive as Promises in metadata image routes and sitemap({ id }) | Already awaited in the post OG route; generateSitemaps is not used |
priority on next/image is deprecated in favor of preload; the docs prefer loading="eager" or fetchPriority="high" in most cases | Every hero |
images.qualities defaults to [75] and other values are coerced; minimumCacheTTL rises from 60 seconds to 4 hours | Nothing on this site sets a quality |
ImageResponse is up to 2x faster and its default font moves from Noto Sans to Geist Sans (16.2) | Both OG routes use the default font, so the cards will change look |
Node 20.9 or later and TypeScript 5.1 or later are required.
Mistakes I see
- The silent client page. No error, no warning, just the layout's title on every route. Grep for
"use client"inpage.tsxfiles and split each one. - Noindex behind a disallow. The tag cannot work if the crawler cannot fetch the page.
- Fake freshness.
lastmodset to the build time on every URL, which teaches Google to ignore the field. - The disabled lint rule.
@next/next/no-img-elementturned off to silence warnings. My site did that, and 194 MB of unsized images followed. - Analytics installed, never rendered.
@vercel/analyticssat inpackage.json, absent from the layout. - Checking the JSX instead of the HTML. The metadata export looked right. The built page was what was wrong.
What it costs / what to expect
The SEO layer of my own site took one working day on September 22, 2026, with Claude Code doing most of the typing and me reading every diff, and it shipped in four commits. For a client site, the same list is the advanced SEO line in the Premium Power tier on the Quick Launch page, and the technical services page is where a retrofit on an existing Next.js site starts. Prices there are current as of September 2026; confirm on the page before you quote me to yourself.
Afterward, expect nothing dramatic in a week. The checklist makes a site legible; it does not make it rank. Search Console's URL Inspection will tell you whether each page was fetched, indexed, and given the canonical you intended, and that is the only score I am willing to name.
Where to go next
The case study of the rebuild has the audit that produced this list. The image piece covers item 9 in depth. Once the pages are right, automating Search Console and IndexNow gets them fetched without a button click per URL.
Have a Next.js site that never got this list?
Send me the URL. I will tell you which of the twelve it fails before we talk about money.
Start a conversationQuestions people ask
- Can a "use client" page export metadata in Next.js?
- No. The Metadata API only works in server layout and page files. If a page starts with "use client", Next silently uses the parent layout's title and description. Keep page.tsx on the server, export metadata there, and move the interactive code into a child client component.
- Do I need both robots.txt and a noindex tag?
- Not for the same URL. A noindex tag only works if Googlebot can fetch the page, and a robots.txt disallow stops it from fetching. Use noindex for pages that should stay out of results, and reserve disallow for paths you do not want crawled at all, such as an admin app or an API.
- Does Next.js 16 break a Next.js 15 SEO setup?
- Mostly no. The Metadata API, robots.ts, sitemap.ts, and next/og carry over. What changes: Turbopack becomes the default and a custom webpack config fails the build, middleware.ts becomes proxy.ts, params arrive as Promises in metadata image routes, and the next/image priority prop is deprecated in favor of preload.
- What should a sitemap lastmod contain?
- A real modification date or nothing. Google says it uses lastmod only when it is consistently and verifiably accurate, and it ignores changefreq and priority entirely. My sitemap sets lastModified only on blog posts, which carry a real date, and omits it on static pages.
- How do I check that Next.js actually emitted my metadata?
- Read the prerendered HTML, not the JSX. After next build, every static route has an HTML file under .next/server/app. My build gate parses those files for a title, description, canonical, og:image, one H1, and valid JSON-LD, and fails the deploy when any is missing.
Sources
- https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image
- https://nextjs.org/docs/app/api-reference/components/image
- https://nextjs.org/docs/app/guides/upgrading/version-16
- https://nextjs.org/support-policy
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
- https://developers.google.com/search/docs/crawling-indexing/block-indexing
- https://developers.google.com/search/docs/appearance/structured-data/article
- https://nextjs.org/blog/next-16-2