Mock X post embed showing the raw fallback blockquote text that displays when the embed script fails to execute after a Next.js client-side navigation

Why Instagram, TikTok, Twitter, and Other Social Embeds Break on Next.js Route Changes

If you’re pulling blog content from a headless CMS into Next.js and rendering it with dangerouslySetInnerHTML, you’ve probably run into this: Instagram, TikTok, and Twitter/X embeds render fine on a hard refresh or the first page load, then silently fail to render on every subsequent client-side navigation. Click into a post from a listing page and the embed is just… gone. Hit refresh on that same URL and it comes back.

This isn’t a caching bug, and it isn’t ISR misbehaving. It’s a structural conflict between how dangerouslySetInnerHTML inserts markup and how the browser executes <script> tags, and it shows up regardless of what’s feeding the content. It happens with WordPress’s REST API, which is how I typically pull post content into a headless Next.js or Express frontend and the most common real-world trigger for this bug based on the public reports, but the underlying cause has nothing to do with WordPress specifically. It happens with Contentful, Sanity, Strapi, a flat-file markdown pipeline, or any other content source that returns raw HTML containing a <script> tag.

TL;DR

  • Browsers never execute <script> tags injected via innerHTML (which is what dangerouslySetInnerHTML does under the hood). This is true in any React app, not just Next.js.
  • On a full page load, Next.js’s SSR pipeline gets a clean pass to inject and run those embed scripts once, so everything works.
  • On a client-side route change (<Link>router.push), React only patches the DOM diff. The embed script tag is never re-executed, so Instagram, TikTok, and Twitter widgets never initialize.
  • This has been reported against Next.js since at least 2019, across WordPress-fed content, unspecified content sources, and even scripts loaded directly with next/script outside any CMS. It’s spec-defined browser behavior rather than a bug Next.js can patch. There’s no framework-level fix, only workarounds.
  • Server-rendered templating (Express with EJS, in my case) doesn’t have this problem, because every navigation is a full page load and the embed script runs the way the platform intended.

The symptom, step by step

  1. You fetch a WordPress post via the REST API. The post body contains raw embed HTML: an Instagram <blockquote> with platform.instagram.com/en_US/embeds.js, or a Twitter/X <blockquote> with platform.twitter.com/widgets.js.
  2. You render that body with dangerouslySetInnerHTML={{ __html: post.content }}.
  3. On first load or a hard refresh, the embed renders correctly.
  4. Navigate to that same post from anywhere else in the app using client-side routing, and the embed area stays empty, or shows the raw fallback blockquote text with no styling.

This is reproducible with Instagram, TikTok, Twitter/X, Facebook post embeds, Pinterest widgets, Reddit embeds, LinkedIn badges, CodePen embeds, Disqus comment widgets, and Google Maps embeds. Anything that depends on an external script scanning the DOM for a specific class name or data attribute after the page loads is vulnerable to the same failure.

Why it happens

dangerouslySetInnerHTML is React’s escape hatch for injecting raw HTML strings into the DOM, bypassing React’s own rendering. The browser will happily parse and display the markup, but by design it will not execute any <script> tag that arrives through innerHTML. This has been true of the DOM since long before React existed. It’s a security default, not a Next.js decision.

A full page load sidesteps this entirely. When Next.js server-renders a page and sends it to the browser as real HTML, the browser’s initial HTML parser handles it, and that parser does execute <script> tags found in the document. That’s why the embed works on first load and on refresh: the script tag never passes through innerHTML on that request.

A client-side route change is a different code path. Next.js’s router swaps out components without a full document reload, React reconciles the DOM, and any script tag in the new content is inserted the same way it was on load, as a raw HTML string, through dangerouslySetInnerHTML. But the browser already parsed and executed scripts once for the initial document; it has no reason to re-run a script tag that shows up later inside a DOM mutation, so the Instagram or Twitter widget library never runs against the new embed markup.

This isn’t a Next.js quirk either. It’s spec behavior. In the accepted answer to one of the threads cited below, a Next.js core team member points to the MDN documentation on innerHTML security considerations, which references the HTML spec’s explicit statement that a <script> tag inserted via innerHTML should not execute. Every browser follows this rule. React’s dangerouslySetInnerHTML is just a thin wrapper around innerHTML, so it inherits the same restriction.

That restriction is worth defending on its own merits, not just noting. If browsers ran scripts inserted through innerHTML by default, any HTML-injection point, a comment field, a rich-text editor, a CMS body, would double as a stored-XSS vector, since anyone able to get raw HTML into that field could get arbitrary JavaScript to execute in someone else’s browser. The name dangerouslySetInnerHTML exists precisely to flag that you’re opting into that raw-HTML surface, and the browser’s refusal to auto-run scripts inside it is a real safeguard, not an arbitrary inconvenience. Next.js inherits this correctly, and there’s nothing to fix at that layer.

Where Next.js does have some room to answer for is the layer above it. The legitimate case, content you already trust because it came from your own CMS, with a script tag you actually want to run on navigation, has no first-class, framework-sanctioned way to handle it. A sanctioned “re-run scripts in this container after route change” utility could exist without weakening the underlying security default at all, since it would only apply to content the developer already opted into rendering. Next hasn’t shipped anything like that, despite the pattern being reported since 2019, which leaves every team hand-rolling close to the same useEffect workaround shown below. So the fair critique isn’t that Next.js got something wrong on security, it inherited the correct browser behavior. It’s that Next.js has left a common, legitimate use case without decent tooling for a long time.

Rendering pathDoes the embed script execute?
Full page load / hard refresh (any framework)Yes, the browser’s HTML parser runs it
Next.js client-side navigation (<Link>router.push)No, inserted via innerHTML, spec says it won’t run
Any client-side router patching the DOM (Nuxt, SvelteKit, Remix, React Router SPA)No, same restriction applies
Server-first frameworks where navigation is a full request (Express/EJS, Rails, Django, Laravel, Astro default)Yes, every time, no exceptions

This is documented, and it isn’t a WordPress bug

This shows up repeatedly in Next.js’s own GitHub discussions and issue tracker, across different versions, both routers, and different content sources. Splitting the reports out by what was actually feeding the content:

Confirmed WordPress REST API:

  • Issue #7555 (2019): the reporter’s code uses post.acf.volanta and post.content.rendered, the .rendered field naming that comes straight out of the WordPress REST API response shape, alongside the ACF plugin.
  • Discussion #51046 (2023): the reporter states outright that the content is “generating static sites from WordPress content.” The accepted answer, from a Next.js core collaborator, confirms it’s expected browser behavior tied to the HTML spec, not a Next.js-specific bug, and that the only real fix is to manually re-parse and re-append the content on navigation.

Content source unspecified (same failure, different platforms):

  • Discussion #17919 (2020): opened about Facebook and Twitter embeds with no CMS named, and later comments in the same thread report identical behavior with Disqus.
  • Discussion #16677 (2020): an Instagram embed case with no CMS named, still unanswered.

Not a CMS issue at all:

  • Issue #57023 (2023), filed against Next.js 13.5, doesn’t use dangerouslySetInnerHTML or any CMS. It uses next/scriptdeclared directly in a component, and the Twitter, Instagram, DICE, and SweepWidget scripts still fail to reload after navigating away from a route and back, with multiple other developers confirming the same result in the thread.

That last one is the strongest evidence that this is architectural. Even when you do everything the “correct,” framework-recommended way, using next/script instead of injecting raw HTML, revisiting a route that already loaded the script once still doesn’t guarantee it re-executes. WordPress is simply the most common trigger in the wild, because pulling post content through the WP REST API and rendering it with dangerouslySetInnerHTML is one of the most popular ways developers build a Next.js front end on top of a headless CMS. Swap in Contentful, Sanity, Strapi, or hand-written markdown, and the same script-execution rule applies.

The fix: re-triggering the script on route change

Since Next.js won’t re-run the script for you, you have to do it yourself on every route change. The common pattern is to hook into navigation events and manually re-create the script tag.

Pages Router:

import Router from 'next/router'
import { useEffect } from 'react'

useEffect(() => {
  const reloadEmbedScript = (src) => {
    const existing = document.querySelector(`script[src="${src}"]`)
    if (existing) existing.remove()
    const script = document.createElement('script')
    script.src = src
    script.async = true
    document.body.appendChild(script)
  }

  const handleRouteChange = () => {
    reloadEmbedScript('https://platform.instagram.com/en_US/embeds.js')
    reloadEmbedScript('https://platform.twitter.com/widgets.js')
  }

  Router.events.on('routeChangeComplete', handleRouteChange)
  return () => Router.events.off('routeChangeComplete', handleRouteChange)
}, [])

App Router:

'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'

export default function EmbedRefresher() {
  const pathname = usePathname()

  useEffect(() => {
    const script = document.createElement('script')
    script.src = 'https://platform.instagram.com/en_US/embeds.js'
    script.async = true
    document.body.appendChild(script)
    return () => script.remove()
  }, [pathname])

  return null
}

This works, but it has a real cost. You’re now manually managing a script lifecycle the browser used to handle for free, re-fetching and re-executing platform SDKs on every navigation instead of once per session. On a content site with embeds scattered across hundreds of posts, that adds up.

Why server-first, full-page-navigation architectures avoid this entirely

The variable that matters here isn’t React versus something else, and it isn’t really Express versus Next.js. It’s whether every navigation is a full HTTP request that returns a complete HTML document, or whether the framework intercepts navigation on the client and patches the DOM instead. The first category never triggers this bug. The second category does, regardless of which client-side framework is doing the patching.

A React app with no client-side router, or one where a link click reloads the page instead of being intercepted, would behave exactly like a server-rendered PHP or Rails app here: the browser’s native HTML parser handles every navigation, so it executes the embed script every time. The same applies to Nuxt, SvelteKit, or Remix once they’re configured for client-side transitions instead of full reloads. This isn’t a React problem or a Next.js problem specifically, it’s what happens whenever a framework’s client-side router takes over the job the browser would otherwise do on its own.

Some of my own projects went through this migration, off Next.js and onto Express with EJS templating, for reasons that had nothing to do with this bug specifically. This class of failure simply disappeared as a side effect. Every page navigation in a server-rendered EJS app is a real HTTP request that returns a complete HTML document. There’s no client-side router intercepting the navigation and no dangerouslySetInnerHTML step at all, because EJS interpolates the CMS content directly into the template as part of the response the browser parses natively. The same holds for Rails, Django, Laravel, plain PHP, ASP.NET, or Astro in its default zero-JS output mode: as long as navigation triggers a fresh document load, the browser’s HTML parser sees the embed script tag the same way every time, first load or the two-hundredth click-through, and executes it every time.

That’s the actual tradeoff. Client-side routing buys faster transitions between pages by keeping the app’s JavaScript state alive across navigation, but it opens a permanent gap between content that renders and content that behaves, and third-party embed scripts fall right into that gap. A server-first request/response cycle never has that gap, because there’s no persistent client-side DOM state to keep in sync in the first place. It’s a genuine architectural tradeoff, not a verdict on any single framework: you’re choosing between faster perceived navigation and giving up the guarantee that arbitrary embedded scripts keep working without extra code.

Should you avoid Next.js because of this?

No, and that’s not the point of writing this up. Next.js is a reasonable choice for a lot of applications, and this bug is narrow: it only bites you if you’re injecting third-party embed scripts through dangerouslySetInnerHTML and relying on client-side navigation. If your content doesn’t include social embeds, or you’re willing to maintain the manual re-injection workaround, it’s a non-issue.

But if a meaningful share of your CMS content includes Instagram, TikTok, or Twitter embeds, it’s worth knowing this going in rather than discovering it in production after a client-side navigation quietly breaks the thing your content actually depends on. It’s also worth asking, honestly, how much of the App Router’s client-side navigation model you actually need for a content site, versus how much complexity it adds to solve a problem a plain server-rendered request never had.

Frequently asked questions

Why does my Instagram embed work on refresh but not when I navigate to it with a Link?

Because a refresh is a full page load that the browser’s native HTML parser handles, including executing script tags. A <Link> navigation is handled by React on the client, and script tags inserted via dangerouslySetInnerHTML during that process are never executed by the browser.

Does the next/script component fix this?

Not reliably. For scripts embedded inside a string of raw HTML from dangerouslySetInnerHTMLnext/script doesn’t help at all since it never sees that script tag. But even when a script is declared directly with next/script in the component tree, the correct, framework-recommended way, reports show it can still fail to reload after a user navigates away from a route and back to it. The safest fix in either case is manually re-triggering the script on route change using router.events or usePathname, as shown above.

Is this a WordPress-specific problem?

No. WordPress’s REST API is the most common trigger in the public bug reports, since pulling post content that way and rendering it with dangerouslySetInnerHTML is a common pattern for headless WordPress and Next.js setups, but the same failure has been reported with unspecified content sources and even with scripts that never touched a CMS at all. The cause is how the browser handles <script> tags that arrive after the initial page load, not anything about WordPress’s data format.

I’ve been building on this exact pattern, a headless WordPress instance feeding a JavaScript frontend through the REST API, since it was still a fairly unusual approach, including an early version of the setup with Nuxt.js. The framework changes, but content.rendered arriving as a raw HTML string and needing to be injected somehow is the same problem every time, whether that’s v-html in Vue or dangerouslySetInnerHTML in React. If you’re dealing with the related problem of Gutenberg block styles not carrying over into a headless frontend, I wrote about solving that styling gap separately. If you’re self-hosting the WordPress backend that feeds this pipeline rather than using a managed host, I’ve written a guide to installing it on Ubuntu Server.

Is this fixed in the App Router or newer Next.js versions?

No, and it isn’t the kind of thing a version bump can fix. The failure has been reported against both the Pages Router and the App Router, across multiple major Next.js versions, because the cause is the HTML spec’s rule against executing scripts inserted via innerHTML, not a Next.js bug. There is no built-in fix, only manual workarounds.

Does this affect TikTok embeds too?

Yes. Any third-party embed that depends on a script scanning the DOM after load (Instagram, TikTok, Twitter/X, Facebook, Pinterest, Reddit, LinkedIn, Disqus, Google Maps) is vulnerable, since the underlying cause is about how the browser handles script tags inserted via innerHTML, not anything specific to one platform’s embed code.

Does this affect YouTube embeds?

No, and it’s worth knowing why not. A standard YouTube embed is an <iframe>, not a <blockquote> plus a companion script. Browsers render <iframe> elements immediately regardless of how they were inserted into the DOM, so there’s no script that needs to execute after the fact. The same logic applies to any other embed that’s a self-contained iframe rather than a script-based widget.

If you’re setting up a headless WordPress instance from scratch rather than retrofitting an existing one, Mainframe is a minimal theme I built specifically for that purpose, stripped down to the REST API essentials instead of carrying the weight of a traditional front-end theme.

RSS Feed Newsletter
Contact us

Latest Blog Posts