Skip to content

Add a Next.js tab to the ESM bundler setup - #8128

Merged
HarelM merged 8 commits into
maplibre:mainfrom
martinfrancois:docs/nextjs-worker-setup
Aug 11, 2026
Merged

Add a Next.js tab to the ESM bundler setup#8128
HarelM merged 8 commits into
maplibre:mainfrom
martinfrancois:docs/nextjs-worker-setup

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Launch Checklist

  • Confirm your changes do not include backports from Mapbox projects
  • Briefly describe the changes in this PR
  • Link to related issues
  • N/A Include before/after visuals or gifs
  • Write tests for all new functionality
  • N/A Document any changes to public APIs
  • N/A Post benchmark scores
  • N/A Add an entry to CHANGELOG.md under the ## main section (no runtime change, and recent test- and docs-only PRs here skip it)
  • Confirm you have read our AI policy.

What this adds

  • One new === "Next.js" tab in the ESM bundler setup in docs/index.md.
  • test/integration/bundler/nextjs/, a Next.js example covering Turbopack through the recipe the tab documents.
  • Two changes to test/integration/bundler/bundlers.test.ts: it now waits for the map to request a vector tile, and it serves each example's own build output as the site root rather than the repo root, accepting Next's out/ alongside dist/.

Closes #8126

Why

The ESM section covers Vite, webpack, esbuild and Rollup. Next.js isn't covered, which is what #8126 asks for, and it can't simply reuse the webpack tab.

Both of Next's bundlers turn the documented webpack recipe

setWorkerUrl(new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url).toString());

into a hashed asset under /_next/static/media/, which the browser then fetches successfully. What they do not emit is the worker's maplibre-gl-shared.mjs sibling, so the worker 404s on its first import and dies before it can handle a message. This is the same failure the Vite tab already documents for plain ?url.

Because a failed worker isn't currently surfaced (#8018), the result is a map that mounts, gets a canvas with a live WebGL 2 context, fires styledata and render, and then never requests a single vector tile. It just sits there and there's nothing in the docs pointing the way out.

What I verified

Chromium 151 via Puppeteer, WebGL 2 available, recording the URL passed to the Worker constructor and counting actual .pbf requests.

The documented webpack recipe: worker fetched with HTTP 200, maplibre-gl-shared.mjs 404, zero vector tiles, in every combination tried.

Next maplibre mode
16.3.0 6.2.0 next build (Turbopack)
16.3.0 6.2.0 next build --webpack
16.2.12 6.2.0 next build (Turbopack)
16.2.12 6.2.0 next dev
16.2.12 6.0.0 next build (Turbopack)

Serving both files from public/ and pointing setWorkerUrl at the worker loads tiles normally under both next build (Turbopack) and next build --webpack.

An earlier revision of this description attributed the failure to Next inlining the entry module, with maplibre then falling back to new Worker("", {type: "module"}). That is not what happens. The constructed URL is the hashed asset path in every run above, never empty. The visible symptom is identical either way, which is why the wrong mechanism held up for so long.

bundlers.test.ts would not have caught any of this: it only checked for a .maplibregl-canvas element, which a dead worker still produces. The tile assertion it now makes was verified by pointing the new example back at the webpack recipe and watching it fail.

Why a pre hook and not postinstall

#8126 suggested postinstall, which is the more natural hook since the copy's only input is node_modules. Measured on pnpm 11, it is not sufficient on its own: package managers skip lifecycle scripts when an install has no work to do.

scenario worker restored
fresh node_modules yes
pnpm install, deps current, destination deleted no
pnpm install --force, same no

It also does not run under --ignore-scripts. In both cases the result is a missing worker and a blank map, the same silent failure the tab exists to prevent. A pre hook runs at the moment the file is needed, so the tab uses that.

Two further traps, both silent, both hit while applying this to a real project:

  • npm lifecycle prefixes match the exact script name, so prebuild runs before build and not before a custom script such as build:local. A project whose CI builds through a custom script gets no worker and a blank map. That is exactly what happened to me, and it went unnoticed because the destination directory was still present from an earlier manual run. This one is called out in the tab.
  • Adding the destination to .gitignore does nothing if the files were already committed, and a stale committed copy keeps working after a maplibre upgrade while running the old worker against the new entry. Left out of the tab to keep it short.

Placement

The tab sits after Rollup and before "CDN / No bundler", as its own tab rather than folded into the webpack tab. Happy to move it either way.

Note

This documents a workaround. If #8024 lands and failed workers start reporting an error, the tab is still needed, but the surrounding text could get shorter.

Closes the gap raised in maplibre#8126: the ESM section covers Vite, webpack,
esbuild and Rollup, but not Next.js, whose default bundler is Turbopack.

Next.js inlines the entry module, so the documented webpack recipe,
new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url), does
not resolve to the worker there. Verified against Next 16.2.12 with both
`next build` (Turbopack) and `next build --webpack`: the map mounts and
gets a canvas with a live WebGL 2 context, styledata and render fire, and
then no vector tile is ever requested, because maplibre ends up calling
new Worker("", {type: "module"}).

The tab documents serving the worker from public/ and pointing
setWorkerUrl at it, which was verified working on both bundlers.

Two details are called out because both are silent failures if missed:
maplibre-gl-shared.mjs has to be copied alongside the worker, since the
worker imports it by relative path; and the copy belongs in a prebuild
step rather than in version control, so it cannot fall out of step with
the installed version and run an old worker against a new entry.

Assisted-By: Claude Opus 5 (Claude Code)
@HarelM

HarelM commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

To be fair, next.js is not a bundler, so I don't think it should have a section of its own.
If you want to add turbopack to bundlers here, you are welcome to do it:
https://github.com/maplibre/maplibre-gl-js/tree/main/test/integration/bundler

If I'm wrong and next.js is using webpack, I would recommend adding a section inside the webpack docs to showcase other options in webpack, including this one.

Does it makes sense?

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.10%. Comparing base (4a10f85) to head (a96ee6f).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #8128   +/-   ##
=======================================
  Coverage   93.10%   93.10%           
=======================================
  Files         300      300           
  Lines       24899    24899           
  Branches     6540     6540           
=======================================
+ Hits        23181    23183    +2     
+ Misses       1718     1716    -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Follows up on the suggestion in maplibre#8126 to use postinstall instead. It is
the more natural hook, since the copy's only input is node_modules, but
it is not sufficient on its own, so the tab now says which to use and
why.

Measured on pnpm 11: package managers skip lifecycle scripts when an
install has no work to do. With dependencies already current and the
destination directory deleted, neither `pnpm install` nor `pnpm install
--force` restored the worker; only a fresh node_modules did. It also does
not run under --ignore-scripts. A pre hook runs at the moment the file is
needed, which is the property that matters.

Two further traps found while applying this to a real project, both
silent, both now called out:

- npm lifecycle prefixes match the exact script name, so prebuild runs
  before build and not before a custom script like build:local. A project
  whose CI builds through a custom script gets no worker and a blank map.
- Adding the destination to .gitignore does nothing if the files were
  already committed, and a committed copy keeps working after a maplibre
  upgrade while running the old worker against the new entry.

Assisted-By: Claude Opus 5 (Claude Code)
@martinfrancois

Copy link
Copy Markdown
Contributor Author

@HarelM That makes sense. Next.js is a framework, not a bundler.

The tricky part is that Next.js uses Turbopack by default, but can also use webpack, and I reproduced the same worker issue with both.

So I'd suggest:

  1. Add Turbopack under test/integration/bundler, using Next.js as the tested case.
  2. Add a short Next.js note to the webpack docs, since next build --webpack also needs this workaround.

I've only tested Turbopack through Next.js, not standalone.

Would that match what you had in mind?

@HarelM

HarelM commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Yes, I think that makes sense. Thanks for looking into it and for your time!

@CommanderStorm CommanderStorm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit of cleanup suggestion to not make this look soo "vibed"

Comment thread docs/index.md Outdated
Comment thread docs/index.md Outdated
Comment thread docs/index.md Outdated
Comment thread docs/index.md
Comment thread docs/index.md Outdated
- drop the reference to the webpack recipe being "above" (tabs have no order)
- use fenced-block title= for the filenames instead of leading comments
- trim the prose around the pre hook and postinstall

Assisted-By: Claude Opus 5 (Claude Code)
…lly load

Adds test/integration/bundler/nextjs/, covering Next's default Turbopack
bundler through the worker setup documented in the Next.js docs tab.

The harness now serves each example's own build output as the site root
rather than serving the repo root, so root-absolute URLs behave the way
they would in a real deployment, and it accepts Next's `out/` alongside
`dist/`.

It also now waits for the map to request a vector tile. The canvas mounts
even when the worker is dead, so canvas presence alone passed builds where
the worker 404'd on its `maplibre-gl-shared.mjs` sibling and no tile was
ever fetched.

Corrects the docs explanation to match measured behaviour: Next does emit
the worker for the webpack recipe, it just doesn't emit the shared sibling.

Assisted-By: Claude Opus 5 (Claude Code)
@martinfrancois

martinfrancois commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two commits. Thanks @HarelM and @CommanderStorm for the quick reviews!

My original explanation was wrong. Next does resolve the worker and does not fall back to new Worker(""). It emits the worker as a hashed asset but not its maplibre-gl-shared.mjs sibling, so the worker 404s on its first import. The map mounts with a live canvas and requests zero tiles, which looks the same from outside. Same failure the Vite tab documents for plain ?url. Reproduced on Next 16.2.12 and 16.3.0, maplibre 6.0.0 and 6.2.0, both bundlers, dev and build. Tab and PR description corrected.

@HarelM I added test/integration/bundler/nextjs/ covering Turbopack as discussed, plus a pointer to it from the webpack tab. test/integration/bundler/bundlers.test.ts would not have caught the bug above, since a dead worker still mounts a canvas, so it now waits for a vector tile request, verified by breaking the example on purpose. It also serves each example's own build output as the site root instead of the repo root, so root-absolute URLs behave as they would in a deployment, and accepts Next's out/ alongside dist/.

One deviation from what we agreed: the Next.js content is still its own tab rather than folded into the webpack tab. @CommanderStorm reviewed the tab later the same day with five inline suggestions on its contents and none on it being separate, so I applied those rather than restructure underneath an open review. Folding it into the webpack tab is a small change, so just say which you'd prefer.

The root tsconfig lists every bundler example under `exclude`, since each
one is a standalone project with its own tsconfig and dependencies. The
new nextjs example was missing from that list, so `npm run typecheck`
compiled its .tsx files with the repo's own `jsx: react` setting and
failed with TS2686 on the React UMD global.

Also lists Next.js in the pointer to the bundler examples.

Assisted-By: Claude Opus 5 (Claude Code)
@martinfrancois
martinfrancois force-pushed the docs/nextjs-worker-setup branch from 35145c8 to cb7ad05 Compare August 9, 2026 22:14
Updated code block titles in documentation for clarity.

@CommanderStorm CommanderStorm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM from my side

@HarelM

HarelM commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The problem I see with adding next.js is that next.js is a framework and not a bundler, if we add it, why not add react, angular, astro, hugo and many others...
It might be beneficial to add a turopack tab instead and mention next.js as it is the most know user of it.

I don't know...
The docs and the code over all looks good, the only problem I see is the "slippery slope" problem...

@martinfrancois

Copy link
Copy Markdown
Contributor Author

The problem I see with adding next.js is that next.js is a framework and not a bundler, if we add it, why not add react, angular, astro, hugo and many others... It might be beneficial to add a turopack tab instead and mention next.js as it is the most know user of it.

I don't know... The docs and the code over all looks good, the only problem I see is the "slippery slope" problem...

I totally understand, I'm going to go with your suggestion, thanks!

martinfrancois and others added 2 commits August 11, 2026 13:41
Addresses the slippery-slope concern in review: the other tabs are named
after bundlers, and a Next.js tab invites Astro, Angular and the rest.
Turbopack is the bundler, so the tab is named for it and says up front
that Next.js is where you will meet it.

Renames the example to test/integration/bundler/turbopack/ to match. It
is still a Next.js app, since Turbopack is not practical to drive
standalone, and both READMEs now say so.

Keeps the note that Next's other mode, next build --webpack, needs the
same setup, because the asset handling is Next's rather than Turbopack's
alone.

Assisted-By: Claude Opus 5 (Claude Code)
@HarelM
HarelM enabled auto-merge (squash) August 11, 2026 11:45
@HarelM
HarelM merged commit ab44bff into maplibre:main Aug 11, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v6 with Next's Turbopack

3 participants