Next.js SEO Audit: 9 Issues I Found and a Fix Checklist
I am building a blog on Next.js, and before putting it in front of a search engine I pointed a small crawler at it. The site had 22 pages. The report came back with nine kinds of issue, and one of them was not real.
This is what the audit flagged, what I changed for each one, and the result I misread for a while. If you only want the list, there is a short Next.js SEO checklist for the App Router near the end.
The setup
The blog is a Next.js App Router site that reads its articles from a separate API. Every page is a Server Component, so the content is in the HTML the server sends. That matters here, because the crawler reads the raw HTML and never runs JavaScript. If your text only appears after the browser runs a script, an audit like this sees an empty page, and so might a search engine. The audit tool is a Node script I wrote some time ago: it crawls every internal link and the sitemap, saves each page's HTML, then runs a list of checks over the saved files.
The checks are simple on purpose:
| Check | Passes when |
|---|---|
| Title length | 50 to 60 characters |
| Meta description length | 120 to 160 characters |
| Canonical | present and equal to the page URL |
| H1 count | exactly one |
| Image alt | every image has alt text |
| Status | every linked page answers 200 |
| Duplicates | no two pages share a title or description |
None of these is a ranking guarantee. They are the things that are cheap to get right and embarrassing to get wrong.
What it flagged
1. Titles that were too short
My page titles were things like About - Blog Wish and Tags - Blog Wish: 16 to 26 characters. Nothing is broken about a short title, but it wastes the one line a search result gives you.
I rewrote the static ones by hand to say what the page is for. Contact - Blog Wish became Contact Blog Wish - Corrections, Story Ideas and Questions, which is 58 characters.
2. Titles that were too long
Article pages had the opposite problem. A 53 character headline plus the site name came to 65, which gets cut off in results.
The fix was to stop adding the site name blindly. A small helper takes a list of candidate titles, tries each with and without the site name, and keeps whichever lands closest to the 50 to 60 range:
const RANGE = { min: 50, max: 60 }
const distance = (length) =>
Math.max(RANGE.min - length, length - RANGE.max, 0)
export const fitTitle = (candidates) =>
candidates
.flatMap((title) => [`${title} - ${SITE_NAME}`, title])
.reduce((best, title) =>
distance(title.length) < distance(best.length) ? title : best,
)
Tag and category pages pass several candidates, from long to short, so a tag called "Cinema" and a tag called "Visual effects" both end up in range:
title: fitTitle([
`${tag.name} - Latest Articles, News, Reviews and Guides`,
`${tag.name} - Latest Articles, News and Reviews`,
`${tag.name} - Articles, News and Reviews`,
])
The same idea handles descriptions, with a 120 to 160 range.
3. Descriptions that were too short
Same cause as the titles. "Log in to your account." is a description, technically. I rewrote the static ones and built the dynamic ones from the page's own description plus a second sentence when there is room.
4. Broken links from my own articles
Two articles linked to category pages that returned 404. I had decided that a category with no published articles should not exist publicly, which sounded tidy until an article linked to one.
I kept the rule for navigation and changed it for the page itself. An empty category is hidden from the header, the footer and the sitemap, but its page still opens, says there is nothing there yet, and carries noindex. Links keep working and search engines are told not to bother.
Those two 404 pages also produced five more lines in the report: duplicate title, duplicate description, missing canonical, no H1, no internal links. All five went away with the same fix. It is worth reading an audit for causes before fixing lines one at a time.
5. Missing canonical tags
Every public page now sets its own canonical through the Metadata API, either the exported metadata object or generateMetadata for dynamic routes. One thing to watch in Next.js: do not put alternates.canonical in the root layout as a default. Any page that does not override it would then claim to be the home page.
I use one function for all page metadata so the canonical, Open Graph and Twitter tags cannot drift apart:
export const pageMetadata = ({ title, description, path, image, index = true }) => ({
title,
description,
alternates: { canonical: path },
robots: index ? undefined : { index: false, follow: true },
openGraph: { type: 'website', siteName: SITE_NAME, title, description, url: path, images: [image ?? SITE_LOGO] },
})
It also matters because a page level openGraph object replaces the layout's one completely. Without a shared function, every page that sets its own image silently loses the site name.
6. Images without alt text
The cover images on article cards had alt="". That is valid HTML for a decorative image, and the headline next to it already names the article. The audit counts an empty alt as missing, though, and so do most tools like it.
I gave covers the alt text stored with the image in the media library, falling back to the article title. It is a small trade: slightly repetitive for a screen reader, clearer for image search.
7. Thin tag pages
This one the audit did not flag, but it is the usual way a blog hurts itself. A tag used on one article produces a page that is nearly a copy of that article's card.
Tag pages with fewer than three articles are marked noindex, follow and left out of the sitemap. They become indexable on their own once the tag earns it.
8. No structured data (JSON-LD)
Also not flagged, and also worth an hour. Each page type now carries JSON-LD: Organization and WebSite on the home page, Article and BreadcrumbList on articles, breadcrumbs on category and tag pages.
The component is five lines. The one detail that matters is escaping <, so that a headline containing </script> cannot close the tag early:
const JsonLd = ({ data }) => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, '\\u003c'),
}}
/>
)
9. No robots.txt or sitemap
The last gap was the two files a crawler looks for first. In the App Router both are code, so they cannot go stale. app/robots.js keeps crawlers out of the admin, sign-in and search pages and points at the sitemap:
const robots = () => ({
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin', '/login', '/register', '/search'],
},
sitemap: `${process.env.APP_URL}/sitemap.xml`,
})
export default robots
app/sitemap.js builds its list from the same API as the pages. It lists only what I want indexed: categories that have articles, the articles themselves, and tags that have passed the three article line. Empty categories and thin tags are left out, so the sitemap and the noindex rules never disagree.
Why the SEO crawler showed every page as slow
After the fixes I added a speed check to the audit script. It already recorded how long each page took to arrive, so I grouped pages into ranges:
| Load time | Pages | Share |
|---|---|---|
| Under 0.5s | 1 | 4.5% |
| 0.5s to 1s | 0 | 0% |
| 1s to 2s | 0 | 0% |
| 2s to 3s | 0 | 0% |
| 3s to 5s | 21 | 95.5% |
| Over 5s | 0 | 0% |
Twenty one of 22 pages took around four seconds. Only the home page was fast.
That pattern is the clue. A real performance problem is rarely this even. The home page was fast because it was requested first, alone. Everything after it was requested 30 at a time.
So I measured by hand:
| Test | Time |
|---|---|
| One page at a time | 0.27s to 0.68s |
| The API on its own | 0.003s to 0.04s |
| 22 requests at once to one page | 1.3s fastest, 4.1s median, 6.3s slowest |
The API was fine. The pages were fine. The crawler was asking a development server for 30 pages in the same instant, the server worked through them more or less in a line, and each page's clock kept running while it waited.
The crawler now asks how many pages to fetch at once. With one at a time the numbers mean something.
They are still development mode numbers. A dev server compiles pages on demand and carries extra scripts, so response times there say little about what visitors get. The real test is a production build, next build followed by next start, measured one request at a time.
This is not special to my script. Most SEO crawlers have a crawl speed or threads setting, and a small server, a dev server or a rate limiter will queue a burst of requests in the same way. If your crawler reports that all pages are slow by about the same amount, lower its concurrency and test a few URLs by hand before you start optimising anything.
A short Next.js SEO checklist (App Router)
This is the list I would start from next time, in the order I would do it:
- Rendering: open View Source and check the main text and links are in the HTML. Keep pages as Server Components.
- Metadata: one function for every page's title, description, Open Graph and Twitter tags, used from
metadataorgenerateMetadata. - Titles and descriptions: roughly 50 to 60 and 120 to 160 characters, and unique per page.
- Canonical: set on every page, never as a default in the root layout.
- Headings and images: one H1 per page, alt text on every content image.
- robots.js and sitemap.js: generated from the same data as the pages, listing only what should be indexed.
- Thin pages:
noindexfor tag pages and empty categories until they have real content. - Structured data: JSON-LD for the site, articles and breadcrumbs.
- Links: crawl the site and fix every internal link that does not answer 200.
- Speed: measure on a production build, one request at a time.
Questions people ask
Are 50 to 60 characters for a title a rule? No. It is roughly what fits on one line of a search result. A title outside the range still works; a title inside it is less likely to be cut off.
Should empty alt text be flagged? An empty alt is correct for an image that adds nothing a screen reader needs. Audit tools flag it because they cannot tell decorative from forgotten. Decide per image type and be consistent.
Should tag pages be indexed at all? Only when they offer something a single article does not. A threshold of a few articles is a simple way to draw that line.
Why does my SEO crawler show all pages as slow? Usually because it requests many pages at once and the server answers them in a queue. Lower the crawl speed or thread count, then compare with one URL loaded on its own.
Do I need the next-seo package with the App Router?
No. The built-in Metadata API covers titles, descriptions, canonicals, Open Graph and robots rules, and robots.js and sitemap.js cover the two files.
Can I audit a Next.js site on localhost? Yes for titles, descriptions, canonicals, headings, alt text and links. Not for speed, unless you run a production build first.
Why not put the canonical in the root layout? Because metadata is inherited. Every page that forgets to set its own would point at the home page, which is worse than having none.
Keeping the report somewhere useful
The audit writes a Markdown report, and a report like this is easier to work through somewhere you can tick lines off and add notes as you fix things. If you want a place for that without making an account, txtnode opens a notepad at any URL you type.
