I was asked to put an interactive Unity WebGL animation on a marketing site. It sounded simple: drop the build output in public/, point a canvas at it, done. It wasn't. The build was far too big to ship as is, and getting compression to work in production exposed a gap in how Netlify's Next.js runtime serves static assets.
The site is a SitecoreAI project with a Next.js head application hosted on Netlify. Content authors build pages in Sitecore, and the Next.js app renders them. The Unity animation had to live inside that setup as a component authors could place on a page, without anyone thinking about WASM or headers.
The problem: WebGL builds are big
A Unity WebGL export produces a handful of files the browser has to download before anything renders:
- a
.wasmbinary (the compiled runtime) - a
.datafile (packed assets) - a
.framework.jsfile (the Unity JS glue code) - a small
.loader.jsbootstrapper
Uncompressed, the binary and data files alone can easily run into tens of megabytes. Mine had a .wasm file north of 20 MB and a .data file in the same ballpark. That's a non-starter for a component sitting on a page that also has to load everything else a Sitecore-driven site loads: layout service data, components, fonts, images.
The fix: pre-compress with Brotli
Brotli compresses WebAssembly and packed binary data far better than gzip. We asked the client to provide the build artifacts already compressed to .br files. That cut the .wasm payload by roughly 75% and the .data payload by about half, turning a ~40 MB initial payload into something much more reasonable. There's no runtime compression cost either, because the compression is done once, ahead of time, not per request.
One honest caveat: this step is manual. The client compressed the files, and we committed the .br files as static binaries. There is no script, CI step or build hook that generates them. It works, but whoever re-exports from Unity has to remember to get the files re-compressed, so folding this into the export or build pipeline is the obvious next improvement.
Unity's loader supports this natively. You point codeUrl, dataUrl, and frameworkUrl at the .br versions of each file instead of the raw ones.
The catch: the browser won't decompress it for you
This is the part I couldn't find documented anywhere. A .br file sitting on disk means nothing to the browser unless the HTTP response explicitly declares Content-Encoding: br. Without that header, the browser hands Unity's loader a pile of raw compressed bytes. The loader expects a plain .wasm or .data file and fails to parse it.
So the real problem wasn't compression. It was getting my hosting layer to declare the right header on a handful of static files.
What didn't work
I tried the two obvious approaches first. Both looked correct on paper and did nothing:
- A
headers()block innext.config.jstargeting the build path. - A
[[headers]]rule innetlify.tomlfor the same path.
Neither took effect. I captured the actual response in a HAR file and the asset was coming back with no Content-Encoding header at all. Static files under public/ on Netlify's Next.js Runtime are served directly from Netlify's asset store for performance. That path bypasses the header-rewriting logic both config mechanisms rely on. If your rule never touches the request, it can't be wrong. It just never runs.
I lost more time on this than I'd like to admit, because I kept re-reading the config instead of looking at the response. Don't trust that a header config "should" work. Check the headers your CDN actually sends for that specific asset path.
What worked: a Netlify Edge Function
I stopped trying to configure the static file server and intercepted the request with a Netlify Edge Function scoped to just that path:
const CONTENT_TYPES: Record<string, string> = {
'.wasm.br': 'application/wasm',
'.data.br': 'application/octet-stream',
'.framework.js.br': 'application/javascript',
};
export default async (request: Request, context: Context) => {
const response = await context.next();
const { pathname } = new URL(request.url);
const extension = Object.keys(CONTENT_TYPES).find((ext) => pathname.endsWith(ext));
if (!extension) return response;
const headers = new Headers(response.headers);
headers.set('Content-Encoding', 'br');
headers.set('Content-Type', CONTENT_TYPES[extension]);
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
export const config = {
path: '/path/to/unity/Build/*',
};
Two details matter here.
The CONTENT_TYPES map does two jobs. It decides which files the function touches at all: anything not ending in .wasm.br, .data.br or .framework.js.br passes through untouched. It also holds the real underlying content type for each one. That second part matters because the static server, left to infer a MIME type from a .wasm.br or .data.br path, won't necessarily guess application/wasm or application/octet-stream. Setting Content-Encoding: br without also correcting Content-Type just trades one broken response for another.
No netlify.toml changes were needed. Netlify auto-discovers any file under netlify/edge-functions/, and the function declares its own route through the export const config = { path: ... } at the bottom. There is no [[edge_functions]] block to add anywhere, so the fix is entirely self-contained in one file.
Edge Functions run at the CDN edge after the origin response comes back, so they can rewrite headers regardless of how the static asset store originally served the file. On top of the encoding and content type, the function adds a year-long immutable cache header, since these build artifacts never change in place.
Because it only matches the Unity build path, the rest of the Sitecore site is untouched. Pages, the layout service proxy and the other routes in the Next.js app never go through it.
The client side
The React component is fairly unremarkable. It lazy-loads Unity's loader.js script, builds a config object pointing at the .br asset URLs, and creates the Unity instance against a canvas ref. It's registered like any other component in the Next.js app, so authors can add it to a page in Pages or Experience Editor.
The one UX detail I'd call out is the loading transition. A CSS loading-ring animation and the actual Unity boot run independently. The overlay only fades out once the Unity instance reports ready and the loading animation has completed at least one full cycle. That avoids a jarring flash on fast connections, where Unity might technically be ready before the loading indicator has shown anything meaningful.
What I'd take from this
- Brotli pre-compression is a big, free win for WebGL/WASM payloads, but only if your server actually declares the encoding.
- Don't assume
next.config.jsor host-level header config applies to everything. Some hosting runtimes servepublic/files through a separate, faster path that skips it. - Check real response headers with a HAR capture or
curl -Ibefore you trust the config. - When you need a header guaranteed on one asset path, an Edge Function is a solid escape hatch.



