Skip to main content

September 2, 2026

Next.js MDX in HTML, not behind Suspense

Crawlers never ran my blog JavaScript, so they got a shell with no article. I flattened the static pages and now test the HTML with scripts turned off.

Sander Korf4 min read
nextjsmdxplaywrightvercel

Blog posts on my Next.js personal site are MDX files on disk. Google, LinkedIn previews, and anyone with JavaScript off want the article in the first HTML response. Mine hid the prose behind nested Suspense, so view source showed a shell, breadcrumbs, maybe JSON-LD, and a thin <main>. Chrome looked fine because React hydrated and the dynamic import resolved. Crawlers do not run the bundle.

This is Next.js 16 App Router. Blog posts and legal pages generate at build time. I still wrapped the article like it was waiting on a network. LinkedIn's scraper shrugged, a no-JS crawl test failed, and I had shipped a pretty empty document.

Crawlers never run the bundle

View source is the honest render. If the article is not in that HTML, it is not in the first response. Hydration can paint it later for humans on a full browser. Googlebot, LinkedIn, and a reader with scripts blocked never get that second chance.

I wanted streaming, shell first and MDX second. That story fits a page that waits on a network. It falls apart for a file that already sits next to the route. generateStaticParams already knows every slug. The MDX module is local. Streaming a null fallback around it delays the only content that matters.

The failure is easy to miss in Chrome. I clicked a post, the title appeared, and I assumed the HTML had it. Disable JavaScript, or curl the document, and <main> was thin. That is the document a crawler files.

Nested Suspense on static MDX

Before flattening, 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: stream the shell, load MDX second, keep loading isolated. fallback={null} looked clean with no skeleton flash. On a static page it meant the first HTML had no article. Two Suspense boundaries, one empty on purpose, around a module already on disk.

The MDX is the page. A later chunk helps a browser that can run JavaScript and leaves crawlers with a thin <main>.

One async page, one import

I collapsed the wrappers into one async server component. It awaits params, awaits cached post metadata, dynamic-imports the MDX file once, and renders <MDXContent /> inside the template. Nested Suspense is gone. There is 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. I would rather ship full HTML than a fast empty shell. Instant nav can keep a shared chrome. A blog post still has to send the prose in the first HTML.

Legal routes got instant = false too, and the no-JS suite opens /legal/cookies for the same reason. Build-time files belong in the first response. A null fallback around them is a hole I will not see in Chrome.

Playwright with JavaScript disabled

Removing Suspense is a claim. I wanted proof that view source would keep the article after the next refactor.

New file: apps/sanderkorf/e2e/no-js-crawl.spec.ts, plus a 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. Shell tests can pass while the article is missing. Body copy cannot.

The 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, and resume get the same treatment. If the heading is only painted after hydration, CI fails.

{
	name: 'no-js',
	use: {
		...devices['Desktop Chrome'],
		javaScriptEnabled: false,
	},
	testMatch: /no-js-crawl\.spec\.ts/,
}

Suspense is for real async boundaries. Static MDX on my own disk is not one. I flattened the page, and if someone re-wraps it in Suspense with a null fallback, the no-JS suite fails before Google notices.


Happy coding! Sander