Real SEO: real 404s, one signal per page and a complete sitemap
Missing pages answer 404 again, every page declares one robots directive and one hreflang, and the default locale stops redirecting to itself. Measured before and after.
This release comes from running a full technical SEO audit on a production site built with the boilerplate. Nearly everything it found came from here, so it gets fixed here. Every item was measured before and after with the same script and the same data.
Missing pages answer 404
A post or a category that did not exist answered 200 with the "not found" page: a soft 404 that
Google can end up indexing. The cause was a loading.tsx at the root of [locale]: under Cache
Components, the status code is sent as soon as a Suspense boundary renders, and a later
notFound() cannot change it.
There is no global loading boundary anymore. Each section has its own, and the blog resolves the
post or category with cached reads before any Suspense, following the pattern in Next's
streaming guide. Every write to posts or categories invalidates that cache, and its short lifetime
makes scheduled posts appear on time.
One directive per page
Every page emitted two robots tags, and missing pages three contradictory ones. Now there is
one, built by buildRobots(). Along the way, /admin and /dashboard, which declared
themselves indexable, are now noindex, nofollow.
The default locale, really at the root
With localePrefix: 'as-needed' the default locale lives without a prefix, but canonicals were
built with it: the home's canonical pointed to /en, which redirected to /. Now no canonical,
hreflang, sitemap entry or link carries the default locale's prefix, and /en/* answers a permanent
308 in a single hop.
The root also stops redirecting by browser language: every URL serves its own language and visitors switch with the selector. That is what search engines recommend.
One hreflang, and lang from the server
hreflang was declared twice, in the HTTP header and in the <head>, and without the page itself. It
now lives only in the <head>, includes the page itself, x-default points at the default locale,
and an article with no translation declares none. <html lang> is rendered by the server instead of
a script setting it after load.
Sitemap and robots.txt
The sitemap declares every locale, the default one without prefix, and a real lastmod instead of
the time it was generated. robots.txt becomes a single block for every crawler and disallows
Next's prefetch and campaign parameters.
Four more bugs
- Titles repeated the brand: "Blog | App | App".
- Next's prefetches shared the 300 requests per minute limit with pages. A listing prefetches dozens of links, so real visitors could get a 429. They now have their own limit.
- Every visit to a post changed its modification date, so
dateModifiedand the sitemap lied. The view counter no longer touches it. - CDN images came out with the domain duplicated in
og:image.
Upgrading from 2.0
- Delete
src/app/[locale]/loading.tsxif you customised it: sections now carry their own, and the skeleton lives in@/shared/components/page-skeleton. - The
/adminand/dashboardlayouts move tofeatures/admin/components/admin-shell.tsxandfeatures/dashboard/components/dashboard-shell.tsx, wrapped by a server layout. - The blog listing moves to
src/app/[locale]/blog/(index)/. The URL does not change. - If you write posts or categories from your own code, call
revalidateTag(BLOG_CACHE_TAG, 'max')afterwards, as the admin routes do. - To redirect the root by browser language again, remove
localeDetection: falsefromsrc/i18n/routing.ts.
The new rules are documented in src/lib/seo/CLAUDE.md, for you and for your agent.