If you landed here from this error, you were probably staring at a perfectly valid root layout and wondering what Next.js wants from you:
Missing required html tags
The following tags are missing in the Root Layout: <html>, <body>.
Quick answer: if you use next-intl (or any locale middleware) with localePrefix: 'as-needed', and the failing URL is a route handler with a dot in it like /blog/rss.xml or /sitemap.xml, your layout is fine. The middleware never rewrote the request to the locale segment, so the URL fell through to a 404 that renders without your <html>/<body>. The fix is a top-level route, not a layout change.
Here is the full chain, because the error message points you at exactly the wrong file.
The error points at the wrong file
The official guidance for this error is short: make sure your root layout renders <html> and <body>, and check for duplicate app folders. That advice is correct for the common case. It is also useless if your layout already looks like this:
// app/layout.tsx — pass-through on purpose
export default function RootLayout({ children }: { children: React.ReactNode }) {
return children
}
That pass-through is not a bug. It is the standard next-intl pattern: the real <html> and <body> live in app/[locale]/layout.tsx, because every page route is supposed to arrive there with a locale segment. The root layout exists only because Next.js requires an app/layout.tsx.
So the question is not "why is my layout wrong." It is "why did a request render through the root layout instead of the [locale] layout."
The real cause: a request the middleware never saw
Locale middleware runs on a matcher. The default next-intl matcher deliberately excludes static-looking files so it does not rewrite your assets. A typical one looks like this:
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:css|js|png|svg|ico|xml|txt|webmanifest)).*)',
],
}
Notice xml and txt in that negative lookahead. They are there for a good reason: you do not want the middleware rewriting /sitemap.xml to /en/sitemap.xml. That would break the file search engines actually fetch.
But the same exclusion catches your RSS feed. If your feed handler lives at app/[locale]/blog/rss.xml/route.ts, here is what happens to a request for the canonical, prefix-less URL /blog/rss.xml:
- The path ends in
.xml, so the middleware skips it. No locale logic runs. - With
localePrefix: 'as-needed', the default locale (en) has no prefix. The rewrite that would turn/blog/rss.xmlinto/en/blog/rss.xmlis exactly the step the middleware just skipped. - The App Router now tries to match
/blog/rss.xmlliterally. Your handler sits under[locale], so it needs three segments./blog/rss.xmlhas two. No match. - Next.js renders a 404. The 404 renders through the root layout, which is your pass-through. No
<html>, no<body>. - You get "Missing required html tags."
The error is real. The diagnosis it offers is not.
Reproduce it in ten seconds
This is the tell. Hit the same feed three ways:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/blog/rss.xml # 404
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/en/blog/rss.xml # 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/de/blog/rss.xml # 200
If the explicit-locale URLs return 200 and the prefix-less one 404s, you have confirmed it. The explicit /en/ and /de/ URLs carry the locale segment in the path, so they match [locale]/blog/rss.xml directly, middleware or not. Only the prefix-less default-locale URL depends on a rewrite that never ran.
The fix: a prefix-less top-level route
Do not weaken the matcher. Removing xml from the exclusion would let the middleware rewrite /sitemap.xml and /robots.txt, which is the opposite of what you want and a genuine way to hurt indexing.
Instead, mirror how sitemap.ts and robots.ts already work: serve the default-locale feed from a top-level route, outside [locale].
// app/blog/rss.xml/route.ts — default locale, prefix-less
import { buildBlogRSSResponse } from '@/lib/blog/rss'
export async function GET() {
return buildBlogRSSResponse('en')
}
Static segments win over dynamic ones in the App Router, so app/blog/ cleanly catches the literal /blog/rss.xml. Keep the [locale] handler for explicit-locale URLs and have both call one shared builder so there is no duplicated feed logic:
// app/[locale]/blog/rss.xml/route.ts — explicit-locale variant
import { buildBlogRSSResponse } from '@/lib/blog/rss'
export async function GET(_req: Request, { params }: { params: { locale: string } }) {
return buildBlogRSSResponse(params.locale || 'en')
}
/de/blog/rss.xml keeps working, /blog/rss.xml starts working, and sitemap.xml stays protected.
Why this matters for search and feeds
A 404 on a linked feed is not just a developer annoyance. Your blog navigation links to /blog/rss.xml, feed readers and crawlers request it, and every one of them hits a broken page. It is a quiet quality signal pointing the wrong way. Fixing the route removes it. And because the matcher stays intact, the files crawlers actually index, your sitemap and robots, are never touched.
Quick answers
My root layout has <html> and <body>. Why the error? Because the failing request is rendering through a different layout, your pass-through root layout, after a 404. The check fires on whatever layout actually rendered.
Does this only affect RSS feeds? No. Any route handler with a dot in the path under [locale] is exposed: rss.xml, feed.xml, a manifest, anything the matcher excludes. The prefix-less default-locale URL is the one that breaks.
Should I just add the locale prefix to my links? You could link /en/blog/rss.xml everywhere, but that leaks the default locale into canonical URLs and fights the whole point of as-needed. The top-level route is cleaner.
Takeaway
When Next.js reports "Missing required html tags" and your layout is obviously correct, stop editing the layout. Ask which layout actually rendered. With locale middleware and as-needed prefixes, a dotted route handler under [locale] is the usual suspect: the middleware skipped it, the rewrite never happened, and a 404 rendered through your pass-through root. Serve the default locale from a top-level route and leave your matcher, and your sitemap, alone.