Enzo Ruffin

PortFolio 2026

How Next.js handles SEO

Metadata, canonical, hreflang, sitemap, OpenGraph images, JSON-LD: what Next.js actually does for search, and how I put it to work on this portfolio.

14 min read
Enzo Ruffin
SEO

Next.js has a reputation for being good at SEO. That is true, but badly phrased. Next gives you tools, it does not produce results: server rendering, a metadata API, convention files for the sitemap and the robots, and share image generation. The rest, correct canonicals, consistent hreflang, structured data, performance, is still on the developer. This site is an interesting case because the constraints stack up: two languages, a blog, and a static export deployed on shared hosting, so not a single Node process is around to fix anything at request time. Here is what Next brings, and what had to be built around it.

It all starts with rendering

Next's first advantage is not a tag, it is that the page arrives already written. With the App Router, pages are Server Components: they read their data at build time and produce complete HTML. A crawler requesting a URL on this site receives the title, the paragraphs, the links and the metadata in the response, without running a single line of JavaScript. That matters, because Google does execute JavaScript, but in a second pass, on a limited budget and with no guaranteed delay. Here the configuration is set to output export: every route becomes an HTML file sitting on disk, WebGL animations included, since everything that moves lives in client components hydrating on top of content that is already readable.

The Metadata API, and the one place that knows about it

Each page exports either a metadata object or a generateMetadata function when the content depends on the URL, which is the case for a blog post. Next then assembles the head: title, description, canonical, OpenGraph, Twitter. On this portfolio no page writes its metadata by hand. They all call a single function, pageMetadata, which takes the locale, the title, the description and the URL pair for both language versions. From that it derives the canonical, the hreflang links, the OpenGraph card and the Twitter card. The point is not writing less code, it is having one place to fix the day a convention changes, instead of ten files quietly drifting apart.

First trap: Next replaces openGraph, it does not merge it

This one is expensive because it is invisible. Metadata from a parent layout is inherited by its pages, except for nested objects: as soon as a page declares its own openGraph, the parent object is replaced entirely, not completed. The result is that the siteName and the image declared in the root layout disappear from the head of every page customizing its share title. The head stays valid, the link preview simply loses the site name, and nothing fails. The fix is one line of discipline: pageMetadata always reinjects the siteName, the OpenGraph locale and the alternate locale, and no page is allowed to write an openGraph of its own.

Second trap: never declare icons in metadata

Next offers two mechanisms for icons. File conventions, where dropping app/icon.png and app/apple-icon.png is enough for the tags to be generated with the right hash. And the icons field of the metadata object. The second silently overrides the first. Declaring icons to add a single size removes the convention files from the head, and the iOS home screen icon with them. On this site the field is deliberately absent and a comment says why, because that is exactly the line a future me would rewrite while thinking it was an improvement.

Canonical and hreflang, from a single source of truth

The alternates field of the Metadata API produces the canonical and the language links. The URLs still have to be right. On this site French lives at the root and English under /en, with translated paths: /mentions-legales on one side, /en/legal-notice on the other. One table describes that mapping, and it feeds the site links, the language switcher, the hreflang tags and the sitemap alike. Articles keep the same slug in both languages, which is the key that pairs the two versions without an extra table. French acts as x-default, since it is the version served by default. Search Console flags a missing x-default as an error, and it has to be declared in two places, in the page tags and in the sitemap.

sitemap.ts, robots.ts, manifest.ts

These are convention files: you export a function, Next generates the matching XML or JSON. The sitemap on this site contains no hand written list. It walks the static routes in both languages, then the article array, and attaches its alternates to every entry. Publishing an article therefore adds four lines to the sitemap without anyone touching it. Two details are worth the detour. The lastmod of fixed pages is a constant updated by hand, definitely not a new Date(): a site announcing every page as modified on every deploy teaches crawlers to ignore its lastmod. And robots.txt explicitly allows /_next/, because blocking JavaScript and CSS means getting a blank page indexed, while disallowing .txt files, which are the client router payloads and would turn into unreadable duplicates in the results.

Share images, generated at build time

Next turns an opengraph-image.tsx file into a PNG, written in real JSX. On this site two generators are enough: a brand card for pages, and a titled card for articles that picks up the title and the category. Every route carries its own file, ten in total across both languages, and the article one wins over the section one because it is more specific. The part not to miss: you never declare that image URL in the metadata object. Next wires it in itself, build hash included. Writing it by hand means freezing a URL that will change on the next deploy, and a broken link preview only shows up the day someone shares the page.

Structured data, where Next does nothing for you

JSON-LD has no dedicated API. You write it into a tagged script, rendered on the server. This is where there is the most to gain, because it tells Google what the page is, not just what it contains. The home page carries three blocks: a full Person entry, a WebSite, and a ProfilePage stating explicitly that this page is a person's profile. The blog declares a Blog, each article a BlogPosting with its publication date, word count, reading time converted to an ISO duration, its category and the note that it is readable without an account. Every inner page adds a breadcrumb, which Google shows in place of the URL in its results. Entities carry stable identifiers, along the lines of #person and #website, which lets an article say it was written by the person described elsewhere on the site rather than declaring a namesake on every page. With one nuance learned along the way: Google does not follow an identifier from one page to another, so the author node stays short but self-contained wherever it appears.

Two languages without duplicating the site

The two languages are two route trees in the same project, each with its own layout and its own lang attribute on the html tag. Shared components receive the locale as a parameter and read their copy from message files. The language switcher is the detail most sites get wrong: it points at the exact equivalent of the current page, articles included, and not at the other language's home page. Sending an English reader from an article to the English home page breaks the very signal the hreflang tags just sent, on top of being annoying.

What Next can no longer do in a static export

A static export has no middleware, no redirects computed per request, no custom headers. Everything Next usually handles at runtime has to be picked up elsewhere, and on shared Apache hosting that elsewhere is called .htaccess. Here it carries four things that bear directly on search. The canonical host, so that www and the bare domain do not serve two indexable versions of the same page. The redirect to HTTPS. An X-Robots-Tag noindex on the host's preview URL, which would otherwise get indexed alongside the domain. And language negotiation, done the old way, with the Accept-Language header and the cookie set by the switcher, as a 302 so a preference is never carved into browser caches.

The trap that puts the whole site behind a 403

This one deserves its own section, because it only shows up once you are live. The export produces both a file and a folder for the same route: out/blog.html holds the page, out/blog/ holds the articles. On a request for /blog, Apache sees the folder first, adds a trailing slash, looks for an index.html that does not exist, and answers 403. Every page on the site is affected except the home page, which gives you an apparently live site where every link leads to an error. The rewrite rule serving the matching .html is three lines long, and it is the part of the file I never touch without retesting it. Same family of problem for the OpenGraph images: they come out of the export with no extension, so Apache gives them a default type, and no link preview shows up until you force a Content-Type of image/png.

Performance is part of SEO

Core Web Vitals do not lift a site on their own, but a slow page loses visitors before ranking even enters the conversation. The biggest win on this project did not come from an image setting, it came from an index file. Component folders exported a barrel, and importing that barrel from a page loaded every module in the folder: the home page, the blog and the contact page were shipping Three.js for a component only used on article pages. Five hundred and fifty kilobytes of JavaScript parsed for nothing, on the three pages that get the most traffic. The fix was a change of imports, pointed at the concrete modules. The second point is LCP: the hero image is an img tag with a high fetchPriority, which is enough for React to emit a responsive preload itself, placed higher in the head than a hand written one would be.

How I check that it holds

SEO on a static site has a rare advantage: everything is verifiable on disk, before deploying anything. After a build I open the files in out and look in the head for what has to be there: a single canonical, three language links including the x-default, the OpenGraph siteName present on every page, the convention icons still in place, and the complete JSON-LD. The structured data blocks go through Google's rich results test. The sitemap can be read by eye: the number of entries should match the number of pages times two languages. Then Search Console takes over, with two reports worth a regular visit, indexing and international targeting, which complains quickly and rightly when an hreflang points at a page that does not point back.

What I take away from it

Next.js handles the mechanical half of SEO, and handles it well: complete HTML, typed metadata, a generated sitemap and robots, share images produced at build time. What it does not handle are the decisions. Which URLs are canonical, how the languages pair up, what the site declares itself to be, what you agree to load on the home page. Those choices do not live in a plugin, and they are exactly what separates a technically clean site from one search engines actually understand. On this portfolio the rule I set myself is simple: one source of truth for URLs, one function for metadata, and nothing retyped by hand inside a page.

14 min read
Enzo Ruffin