Dynamic Edge Theming for ISR Payloads via Envoy Response Manipulation and Pre-compiled SCSS Artifacts


Our migration to a micro-frontend architecture using Next.js with Incremental Static Regeneration (ISR) was a massive performance win. Time To First Byte (TTFB) and First Contentful Paint (FCP) metrics plummeted. Pages felt instantaneous. But this velocity hit a wall against a core business requirement: our platform serves over five hundred white-label brands, each with a unique theme defined by an extensive set of Sass/SCSS variables. The initial ISR model broke down here. A theme, in our world, isn’t just a CSS class on a <body> tag; it’s a cascade of hundreds of variables affecting everything from border-radius to font-loading strategies.

Our first attempt was naive. We treated a theme change like a content change. A brand manager updates a hex code in our design system, and we trigger a revalidation of the affected pages. This was a disaster. The ISR cache is path-based. It has no concept of a “theme.” The only way to serve a themed page was to encode the theme in the path (/products/widget?theme=brand-a) or hostname (brand-a.ourplatform.com). This resulted in cache fragmentation and an explosion of build artifacts. Revalidating a single page for 500+ themes was computationally absurd.

The second attempt involved moving the theming logic to the client-side. The ISR page would be served as a generic, un-styled shell. A client-side script would then fetch the theme configuration and apply it using CSS-in-JS. This completely negated the performance benefits of ISR. We were back to a blank screen, spinners, and layout shifts, all the problems static generation was meant to solve.

The technical pain point was clear: we needed to decouple the static HTML structure, which changes infrequently, from the theme (CSS), which can change independently and needs to be applied dynamically per request. The static page should be generated once, and the theme applied just-in-time. The “just-in-time” location couldn’t be the client browser, and it couldn’t be the Next.js build server. It had to be at the edge.

Our entire infrastructure already routes through an Envoy Proxy mesh for ingress control, security, and observability. This became the focal point of our new concept: what if Envoy, which already sits between the user and our ISR cache, could intercept the static HTML response and inject the correct theme before forwarding it to the user? This was a radical idea. It meant using a network proxy for what is traditionally a front-end build concern. The coolest part is that if it worked, we could achieve dynamic, per-request theming on completely static files with near-zero latency overhead.

The Theming Artifact Pipeline

First, we needed a way to generate all possible CSS theme variations ahead of time. Our design system’s source of truth is a directory of SCSS files and a master JSON file defining the variable overrides for each of the 500+ brands. The goal is to create a build process that outputs a single, static CSS file for each brand.

This is a perfect task for a simple Node.js script, run as part of our CI/CD pipeline whenever the design system is updated.

// scripts/build-themes.js
const sass = require('sass');
const fs = require('fs/promises');
const path = require('path');

// In a real project, this would be fetched from a database or a configuration service.
const THEMES_CONFIG_PATH = path.join(__dirname, '..', 'themes', 'themes.json');
// The entry point for our core SCSS system.
const SCSS_ENTRY_PATH = path.join(__dirname, '..', 'styles', 'main.scss');
// Directory to output the compiled CSS files.
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'themes');

// A simple logging utility.
const log = (message) => console.log(`[ThemeBuilder] ${message}`);

/**
 * Generates the SCSS variable override string for a given theme.
 * @param {object} themeVariables - Key-value pairs of SCSS variables.
 * @returns {string} - A string of SCSS variable declarations.
 */
function generateThemeOverrideData(themeVariables) {
    return Object.entries(themeVariables)
        .map(([key, value]) => `$${key}: ${value};`)
        .join('\n');
}

/**
 * Main function to compile all themes.
 */
async function compileAllThemes() {
    try {
        log('Starting theme compilation...');
        await fs.mkdir(OUTPUT_DIR, { recursive: true });

        const themesConfigFile = await fs.readFile(THEMES_CONFIG_PATH, 'utf-8');
        const { themes } = JSON.parse(themesConfigFile);
        log(`Found ${themes.length} themes to compile.`);

        const coreScssContent = await fs.readFile(SCSS_ENTRY_PATH, 'utf-8');

        const compilationPromises = themes.map(async (theme) => {
            const themeId = theme.id;
            if (!themeId || !theme.variables) {
                log(`Skipping invalid theme entry: ${JSON.stringify(theme)}`);
                return;
            }

            const startTime = performance.now();
            const themeOverrideData = generateThemeOverrideData(theme.variables);
            
            // We prepend the dynamic variables to the main SCSS file content.
            // This allows the variables to be defined before they are used.
            const scssSource = `${themeOverrideData}\n${coreScssContent}`;
            
            const result = sass.compileString(scssSource, {
                style: "compressed", // Minify the output for production.
                // We don't need source maps for these artifacts.
                sourceMap: false, 
            });

            const outputFilename = `${themeId}.css`;
            const outputPath = path.join(OUTPUT_DIR, outputFilename);
            await fs.writeFile(outputPath, result.css);

            const duration = (performance.now() - startTime).toFixed(2);
            log(`Compiled ${outputFilename} in ${duration}ms.`);
        });

        await Promise.all(compilationPromises);
        log('All themes compiled successfully.');

    } catch (error) {
        console.error('[ThemeBuilder] Fatal error during theme compilation:', error);
        process.exit(1);
    }
}

compileAllThemes();

The accompanying themes.json would look something like this:

{
  "themes": [
    {
      "id": "brand-a-dark",
      "variables": {
        "primary-color": "#3498db",
        "background-color": "#2c3e50",
        "text-color": "#ecf0f1",
        "font-family": "'Lato', sans-serif"
      }
    },
    {
      "id": "brand-b-light",
      "variables": {
        "primary-color": "#e74c3c",
        "background-color": "#ffffff",
        "text-color": "#34495e",
        "font-family": "'Roboto', sans-serif"
      }
    }
  ]
}

This script generates brand-a-dark.css and brand-b-light.css. In production, these compiled CSS files are uploaded to a durable, high-performance static asset store like Amazon S3 or Google Cloud Storage, fronted by a CDN. This pipeline completely separates the theme generation from the application’s build and deployment lifecycle.

The Instrumented ISR Application

Next, the Next.js application needs to be prepared for injection. The key is to build the pages completely “theme-agnostic.” They contain the structure and content but no brand-specific styling. We then add a specific, machine-readable placeholder in the HTML <head> where Envoy will later inject the stylesheet link.

// pages/products/[slug].js
import Head from 'next/head';

// This is a standard ISR page.
export async function getStaticProps({ params }) {
  // Fetch product data based on the slug.
  const product = await fetch(`https://api.myapp.com/products/${params.slug}`).then(res => res.json());

  if (!product) {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      product,
    },
    // Revalidate the page every 60 seconds.
    // Content can be updated without a full redeploy.
    revalidate: 60,
  };
}

export async function getStaticPaths() {
  // Pre-render a few popular product pages at build time.
  const popularProducts = await fetch('https://api.myapp.com/products/popular').then(res => res.json());
  const paths = popularProducts.map(p => ({ params: { slug: p.slug } }));
  
  return {
    paths,
    // Other paths will be generated on-demand.
    fallback: 'blocking',
  };
}

function ProductPage({ product }) {
  return (
    <div>
      <Head>
        <title>{product.name}</title>
        
        {/*
          This HTML comment is the critical injection point.
          Our Envoy Lua script will search for this exact string
          and replace it with the <link> tag.
          It MUST be unique and unlikely to appear naturally.
        */}
        {/* ENVOY_THEME_INJECTION_POINT */}

      </Head>
      <main>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
        {/* ... rest of the page components */}
      </main>
    </div>
  );
}

export default ProductPage;

When this page is rendered by Next.js, the output HTML will literally contain <!-- ENVOY_THEME_INJECTION_POINT -->. This is our surgical target. The page itself is now a generic template, ready to be branded at the edge.

The Envoy Configuration and Lua Filter

This is the heart of the solution. We need to configure Envoy to route traffic to our Next.js application and, more importantly, apply a Lua filter to modify the response body. The Lua filter provides a programmable hook into the request/response lifecycle with surprisingly good performance.

Here is the relevant section of our envoy.yaml configuration:

# envoy.yaml
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 10000 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: nextjs_service }
          http_filters:
          # The Lua filter is inserted before the router filter.
          - name: envoy.filters.http.lua
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
              # This Lua script contains our core logic. For production,
              # this would point to a file path (`filename: /etc/envoy/theme-injector.lua`).
              # For demonstration, it's provided inline.
              inline_code: |
                -- This script executes for every request/response pair.

                -- Called on the request path.
                function envoy_on_request(request_handle)
                  -- 1. Read the brand identifier from a custom header.
                  -- A common mistake is to not handle the nil case.
                  local brand_id = request_handle:headers():get("x-brand-id") or "default-theme"

                  -- 2. Validate the brand_id against a safe pattern to prevent header injection attacks.
                  -- This ensures the ID only contains alphanumeric characters, dashes, and underscores.
                  if not string.match(brand_id, "^[a-zA-Z0-9_-]+$") then
                    brand_id = "default-theme" -- Fallback to a safe default.
                  end

                  -- 3. Pass the determined brand_id to the response phase using metadata.
                  -- This is more efficient and cleaner than adding new request headers.
                  request_handle:streamInfo():dynamicMetadata():set(
                    "envoy.filters.http.lua", "brand_id", brand_id
                  )
                end

                -- Called on the response path.
                function envoy_on_response(response_handle)
                  local metadata = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")
                  local brand_id = metadata["brand_id"]

                  -- 1. Only process successful HTML responses.
                  -- This avoids trying to modify JSON APIs, images, etc.
                  local response_code = response_handle:headers():get(":status")
                  local content_type = response_handle:headers():get("content-type")

                  if response_code ~= "200" or not content_type or not content_type:find("text/html") then
                    return
                  end

                  -- 2. Add a Vary header. This is CRITICAL for correctness.
                  -- It tells downstream caches (like browsers or CDNs) that the response
                  -- varies based on the X-Brand-ID header, preventing a user for Brand A
                  -- from seeing a cached page for Brand B.
                  response_handle:headers():add("Vary", "X-Brand-ID")

                  -- 3. The most performance-sensitive part: body manipulation.
                  -- We must buffer the entire response body to perform the replacement.
                  -- This has memory and latency implications for very large pages.
                  response_handle:body():setStreaming(false) -- Disable streaming, enable buffering.

                  -- The on_data callback is invoked when the *entire* body is buffered.
                  response_handle:body():on_data(function(buffer, end_of_stream)
                    local body_str = buffer:toString()
                    local injection_placeholder = "<!-- ENVOY_THEME_INJECTION_POINT -->"
                    
                    -- Construct the full URL to the pre-compiled CSS file.
                    -- In a real setup, this base URL would come from a configuration variable.
                    local css_asset_url = "https://my-cdn.com/themes/" .. brand_id .. ".css"
                    local link_tag = '<link rel="stylesheet" href="' .. css_asset_url .. '">'

                    -- Perform the replacement.
                    local modified_body, replacements = string.gsub(body_str, injection_placeholder, link_tag)
                    
                    if replacements > 0 then
                      -- If replacement occurred, inject the modified body back.
                      response_handle:body():set(modified_body)
                    else
                      -- If the placeholder wasn't found, do nothing to the body.
                      -- You might want to log this as a warning.
                    end
                  end)
                end

          # The router filter must come last to send the request upstream.
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
  - name: nextjs_service
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: nextjs_service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                # This should point to your Next.js application server.
                address: host.docker.internal
                port_value: 3000

The sequence of operations is now crystal clear.

sequenceDiagram
    participant User
    participant Envoy
    participant Next.js (ISR Cache)
    participant S3/CDN

    User->>+Envoy: GET /products/widget-a
Header: X-Brand-ID: brand-a-dark Envoy->>Envoy: Lua (envoy_on_request):
Reads X-Brand-ID,
stores "brand-a-dark" in metadata. Envoy->>+Next.js (ISR Cache): GET /products/widget-a Next.js (ISR Cache)-->>-Envoy: 200 OK
Body: Unthemed HTML with Envoy->>Envoy: Lua (envoy_on_response):
1. Checks status & content-type.
2. Adds 'Vary: X-Brand-ID' header.
3. Buffers HTML body.
4. Replaces placeholder with '' Envoy-->>-User: 200 OK
Body: Themed HTML User->>+S3/CDN: GET /themes/brand-a-dark.css S3/CDN-->>-User: 200 OK
Body: Compiled CSS

A quick test with curl validates the entire flow:

# Request for Brand A
$ curl -i -H "X-Brand-ID: brand-a-dark" http://localhost:10000/products/widget-a

HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: X-Brand-ID
...

<!DOCTYPE html><html><head><title>Widget A</title><link rel="stylesheet" href="https://my-cdn.com/themes/brand-a-dark.css"></head>...</html>

# Request for Brand B on the exact same path
$ curl -i -H "X-Brand-ID: brand-b-light" http://localhost:10000/products/widget-a

HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: X-Brand-ID
...

<!DOCTYPE html><html><head><title>Widget A</title><link rel="stylesheet" href="https://my-cdn.com/themes/brand-b-light.css"></head>...</html>

This completely changes the way we work. The front-end application is now blissfully unaware of theming. The design system pipeline produces CSS artifacts independently. And the infrastructure team manages the routing logic in Envoy. It’s a true separation of concerns that unlocks incredible performance. We can update a brand’s theme and have it go live instantly, globally, with zero application builds or deployments. The ISR page for /products/widget-a is fetched from cache once, and Envoy stamps it with the correct theme for every single request.

Lingering Issues and Future Iterations

This architecture, while powerful, isn’t without its trade-offs and areas for improvement. The most significant is the performance characteristic of the Lua filter. It must buffer the entire response body in memory before it can perform the gsub operation. For a typical HTML page of <100KB, this is nanoseconds of overhead. But for exceptionally large pages, this could introduce noticeable latency and increase Envoy’s memory footprint. A future iteration would be to rewrite this logic as a native C++ filter or, more realistically, a WebAssembly (WASM) filter. WASM would provide near-native performance and memory safety, while still allowing us to write the logic in a language like Rust or Go.

Another critical point is cache correctness. The Vary: X-Brand-ID header is non-negotiable. Forgetting it would cause catastrophic issues where a public cache (like a corporate proxy or a misconfigured CDN) would serve a page themed for one brand to a user from another. Rigorous testing of caching behavior is essential.

Finally, the developer experience needs consideration. A front-end developer working locally doesn’t have this Envoy injection mechanism. We’d need to augment the local Next.js dev server with a small middleware that simulates the Envoy filter’s behavior, allowing developers to pass a query parameter like ?theme=brand-a-dark to see their changes in a specific theme without running a local Envoy instance.


  TOC