DEV Community

Rickey Gu
Rickey Gu

Posted on Originally published at modernize.flexplat.com

How to Render WordPress in Node.js Without Losing Your Plugins

Most headless WordPress setups move the front end into Next.js and read content from the REST API or WPGraphQL. That works well for posts and pages. It works badly for plugins. A contact form, a lightbox or a cookie banner needs styles and scripts that WordPress prints into the page, and the API doesn't send them. So each plugin's front end has to be rebuilt in React, one by one.

I took a different route with Flx Modernize: WordPress keeps handling the request, and a Node.js renderer produces the HTML. This article explains how that works, what it keeps, and what it costs.

The problem, measured

I set up one WordPress page with two ordinary plugins: Contact Form 7 and Easy FancyBox, a jQuery lightbox. Then I loaded the same page three ways: from normal WordPress, through Flx, and through a typical Next.js front end that fetches content.rendered from the REST API, as most tutorials do.

Normal WordPress Flx (Node.js) Next.js + REST
Contact Form 7 stylesheet and script on the page yes yes no
jQuery and the FancyBox script yes yes no
Click on the image lightbox opens lightbox opens browser opens the raw image
Form submitted empty 3 inline error messages 3 inline error messages posts to a route that returns 404

The 60-second recording of this test is on YouTube, and you can try the Flx version on the live demo page (visit it logged out).

The headless result isn't a bug in Next.js. It's how the API works.

Why headless loses the plugin front end

A WordPress plugin usually touches a page in two places:

  1. The content. A shortcode or block in the post becomes HTML. The REST API runs the the_content filters, so the form's markup does reach content.rendered.
  2. The page head and footer. The plugin enqueues its stylesheet and script, and WordPress prints them in wp_head and wp_footer, together with the WordPress scripts they depend on, such as jQuery, and small inline settings.

The REST API returns the first and not the second. So the form's markup arrives without the CSS that styles it or the JavaScript that validates it. With no script attached, submitting falls back to a plain HTML form post, aimed at a URL your front end doesn't handle.

You can rebuild all of that: fetch the form schema, render it in React, post to the plugin's REST endpoint, add the hidden fields and the spam check. Several good tutorials do exactly that. But it's per plugin, and you maintain it whenever the plugin changes.

The idea: let WordPress handle the request, and render elsewhere

Instead of pulling content out of WordPress, Flx lets WordPress run the request as usual, then hands the finished data to a Node.js service for rendering.

The plugin hooks into template_redirect. That runs after WordPress has parsed the request, run the main query and loaded the plugins, and before the theme renders a template. At that point the plugin:

  1. Checks that the request should get a Flx page: an anonymous visitor's GET or HEAD for an HTML page. Logged-in users, previews, drafts, password-protected posts, feeds and visitors with comment cookies always get normal WordPress.
  2. Builds the page data as an anonymous visitor, so content filters and dynamic blocks never see a user: title, content, dates, author, featured image, menus, and for archives the list of posts.
  3. Captures what plugins print in wp_head and wp_footer, using the same callbacks WordPress would run, and filters it (more on that below).
  4. Sends it all as JSON to the renderer, at /api/v1/render/<type>, signed with HMAC-SHA256 and with a 5-second timeout.
  5. Sends the renderer's HTML to the visitor, with Cache-Control: private, no-store unless you turn on edge caching.
  6. Falls back to WordPress on any failure: timeout, error or an invalid response. WordPress then renders the page exactly as it would have without Flx.

In simplified form:

add_action( 'template_redirect', function () {
    if ( ! flx_should_render() ) {
        return; // WordPress renders as usual
    }

    $payload = flx_build_payload();                    // page data, built as user 0
    $payload['head_html']   = flx_capture( 'wp_head' );   // filtered plugin output
    $payload['footer_html'] = flx_capture( 'wp_footer' );

    $html = flx_render( $payload ); // signed POST, 5 s timeout
    if ( null === $html ) {
        return; // any failure: WordPress renders the page itself
    }

    header( 'Cache-Control: private, no-store' );
    echo $html;
    exit;
} );
Enter fullscreen mode Exit fullscreen mode

The real code is in the plugin, which is free software under the GPL; you can read it on WordPress.org.

Capturing the head and footer without copying the theme

The interesting part is step 3. Sending everything WordPress prints would duplicate the theme's own CSS, which the renderer already provides. Sending nothing would lose the plugins. So the plugin keeps a defined set:

  • Kept: the <title>, SEO meta tags, canonical and other link tags, JSON-LD structured data, plugin and external scripts such as analytics, and plugin stylesheets and <style> blocks.
  • Kept when a plugin needs them: WordPress's own scripts and styles that a plugin depends on, such as jQuery, wp-i18n or Dashicons, in the order WordPress prints them.
  • Left out: WordPress's and the theme's styles and scripts that no plugin needs, script modules, emoji, and any other markup.

It's captured as an anonymous visitor, with cookies and the query string hidden, so nothing about the visitor goes into it. Each part is capped at 64 KB; if styles push it over, they're dropped and the renderer falls back to its own title, description and canonical.

That's what makes the table above come out the way it does. Contact Form 7's stylesheet, its script, wp-hooks, wp-i18n and its small inline settings arrive on the page in WordPress's order, so the form validates through Contact Form 7's own REST endpoint, as it does on any WordPress page. Nothing about the plugin was rebuilt.

The renderer

The renderer is a small Express 5 app with Tailwind CSS. It's stateless: no database, no sessions, and it sets no cookies. For each page type it receives the JSON and returns HTML in the theme's look:

  • the header and footer, with menu items taken from the payload rather than hard-coded, so menu edits in WordPress show up without a code change;
  • the content, as WordPress produced it;
  • head_html and footer_html, in the right places.

Because the theme's look is reproduced in the renderer, it's checked against the original. An automated test loads every page twice, once from WordPress and once through Flx, and compares screenshots pixel by pixel on desktop and phone. On the Twenty Twenty-Five demo, pages match about 99% on desktop and 97% on phones (CI run, November 2, 2026).

The Twenty Twenty-Five renderer is open source too: you can download it, and the tutorial walks through building one for a theme, step by step.

What this costs

This isn't free, and it isn't headless in the usual sense:

  • Each theme's look is rebuilt in the renderer. Twenty Twenty-Five works out of the box. Another theme needs a renderer built for it. Elementor works on the Hello Elementor theme, in our pilot.
  • WordPress still serves every request. If you go headless so that WordPress isn't publicly reachable, this doesn't give you that.
  • Logged-in visitors see normal WordPress. So do previews and password-protected content.
  • No speed promise. Pages can be cached at the edge like any site, and on the demo they got lighter. But speed isn't the point; keeping plugins while rendering in Node.js is.
  • Plugins are checked, not guaranteed. I've tested SEO, form and lightbox plugins. A plugin whose script has no file of its own (inline code only) can be left out, unless another plugin's file depends on it.

When it fits

This approach fits a project where:

  • your front-end developers want to work in Node.js and Tailwind;
  • the site has to stay WordPress, with its editor and its plugins;
  • rebuilding each plugin's front end isn't worth the cost.

If you need WordPress hidden from the public internet, or one backend feeding several apps, classic headless is still the better tool.

If you try it, I'd like to hear where it breaks. The plugin is on WordPress.org, the docs are at modernize.flexplat.com, and you can reach me at rickey29@gmail.com.

If you'd rather have us build the renderer for your site, the first ten sites are a free pilot; the details are on the pricing page.

Top comments (0)