Skip to content

Use Maptoolkit Maps in Next.js

Next.js renders components on the server first. A WebGL map library cannot run there: it reaches for window and document while the module is being evaluated, so the import itself throws before any of your code runs. The fix is to keep the map out of the server bundle entirely.

The error you get is ReferenceError: window is not defined, and it points at the library rather than at your component, which sends people looking in the wrong place.

App Router

Mark the map component as client-only, then load it through next/dynamic with server rendering disabled.

components/MaptoolkitMap.jsx:

"use client";

import { useEffect, useRef } from "react";
import { Map } from "@maptoolkit/maps";
import "@maptoolkit/maps/css";

export default function MaptoolkitMap() {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (mapRef.current) return;
    mapRef.current = new Map({
      container: containerRef.current,
      apiKey: process.env.NEXT_PUBLIC_MAPTOOLKIT_KEY,
      center: [16.37, 48.21],
      zoom: 12,
    });
    return () => {
      mapRef.current?.remove();
      mapRef.current = null;
    };
  }, []);

  return <div ref={containerRef} style={{ width: "100%", height: "400px" }} />;
}

app/page.jsx:

import dynamic from "next/dynamic";

const MaptoolkitMap = dynamic(() => import("@/components/MaptoolkitMap"), {
  ssr: false,
  loading: () => <div style={{ height: 400 }} />,
});

export default function Page() {
  return <MaptoolkitMap />;
}

"use client" alone is not enough. A client component is still server rendered for the initial HTML, so the module is still evaluated on the server and still touches window. It is ssr: false that keeps it out.

In recent Next.js versions ssr: false is rejected inside a Server Component, so the dynamic() call has to live in a file that is itself a Client Component. Put "use client" at the top of the file holding the dynamic() call, or wrap the map in a small client-only component that does the dynamic import.

Pages Router

The same shape, without the directive:

import dynamic from "next/dynamic";

const MaptoolkitMap = dynamic(() => import("../components/MaptoolkitMap"), { ssr: false });

export default function Home() {
  return <MaptoolkitMap />;
}

The loading placeholder should reserve the height

loading renders while the chunk downloads. If it returns null or an unsized element, the page reflows when the map appears, which is a visible layout shift and a Core Web Vitals penalty on a component that is usually the largest thing on the page. Give the placeholder the same height as the map.

Where the CSS goes

Import the stylesheet in the map component, next to the library. It travels with the dynamic chunk, so it loads when the map does and does not block first paint.

Importing it in app/layout.jsx or _app.jsx instead works, but pulls map CSS into every route including ones with no map on them.

The API key

NEXT_PUBLIC_ variables are inlined into the client bundle at build time, which is what makes them readable by the browser and therefore by anyone. That is correct for a map key, since the browser has to send it with every tile request. Restrict the key to your domains rather than treating it as a secret.

A key without the NEXT_PUBLIC_ prefix is stripped from the client bundle and arrives as undefined, which surfaces as a 403 Access denied! from the tile host rather than as a missing-variable error.

Static export

output: "export" works. The map is client-only, so there is nothing to prerender, and the dynamic import is resolved in the browser as normal.

Next steps