This was my personal site blog on Next.js 16
Next.js App Router. MDX files on disk. Blog posts and legal pages at build time. Google, LinkedIn previews, and the occasional reader with JavaScript off all want the same thing: article body in the first HTML response. Mine was split across Suspense boundaries.
What failure looks like
View source on a blog post. You see the shell, breadcrumbs, maybe JSON-LD. The prose is missing. <main> is thin. LinkedIn's scraper shrugs. A no-JS crawl test fails. The page looks fine in Chrome because React hydrates and the dynamic import resolves. Crawlers don't run your bundle.
Double Suspense on static MDX
Commit 3896359 on main flattened two routes: app/[locale]/blog/[slug]/page.tsx and the legal twin.
Before: a sync wrapper exported default with <Suspense fallback={null}>. Inside that, an async BlogPostContent fetched page data. Inside that, another Suspense around BlogPostMdx, which dynamic-imported the MDX file.
Every layer made sense in isolation. Streaming shell first. MDX second. Except these pages are static. generateStaticParams already knows every slug. The MDX module is local. There is nothing to stream that helps a crawler.
The fix is boring and correct. One async server component. Await params. Await cached metadata. Dynamic-import the MDX once. Render <MDXContent /> directly inside the template. No nested Suspense. No client split.
export default async function BlogPostPage({ params }: PageProps<'/[locale]/blog/[slug]'>) {
const { slug } = await params;
const pageData = await getCachedBlogPostPageData(slug as Route);
if (!pageData) notFound();
const MDXContent = (await import(`../../../../content/blog/${slug}.mdx`)).default;
return (
<ContentPageTemplate metadata={pageData.metadata} breadcrumbs={pageData.breadcrumbs}>
<MDXContent />
</ContentPageTemplate>
);
}I also set export const instant = false on blog and legal routes. Those pages opt out of Next.js instant-navigation shell validation. Fine. I'd rather ship full HTML than a fast empty shell.
Playwright with JavaScript disabled
Removing Suspense is a claim. I wanted proof.
New file: apps/sanderkorf/e2e/no-js-crawl.spec.ts. New Playwright project in playwright.config.ts with javaScriptEnabled: false. Same routes the instant-nav rig already hits, but assertions target <main> body copy, not shell markers.
Blog post test opens /blog/fullstack-ai-engineer-amsterdam-introduction and expects the title plus two sentences from the article. Legal opens /legal/cookies and expects the policy text. Home, portfolio, resume get the same treatment.
{
name: 'no-js',
use: {
...devices['Desktop Chrome'],
javaScriptEnabled: false,
},
testMatch: /no-js-crawl\.spec\.ts/,
}If someone re-wraps MDX in Suspense with a null fallback, CI breaks before Google notices.
Suspense is for real async boundaries. Static MDX on your own disk is not one. Flatten the page. Test with JS off. Crawlers are the cheapest QA team you'll ever hire.
Happy coding! Sander