Skip to content

Add a map to Next.js

A map is a client-side thing. It measures a DOM element, creates a WebGL context, and fetches tiles as you pan. None of that can happen while a Server Component renders, which is why most Next.js map guides start with a dynamic() import and ssr: false.

You do not need one. Both paths below ship the client boundary inside the component, so a Server Component page renders the map directly.

Install

Two ways in, and the tabs below stay in step with each other for the rest of the page.

Registry copies the component into your project the way every shadcn component arrives. You own the file, it follows your design tokens, and MapLibre's controls and popups are restyled onto them. It needs a project with a components.json.

Package installs @unmap/next and imports <Map> from it. Nothing is copied, so it updates with the SDK, and it works in any Next.js project whether or not you use shadcn.

npx @unmap/cli add map

The page

// app/page.tsx
import { Map } from "@/components/ui/map";
 
export default function Page() {
  return (
    <main className="h-dvh">
      <Map
        apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY!}
        center={[-114.07, 51.05]}
        zoom={11}
        className="h-full"
      />
    </main>
  );
}

That is the whole integration. app/page.tsx stays a Server Component: it never says "use client", and it is not wrapped in dynamic().

The stylesheet, on the package path only

The copied component imports MapLibre's stylesheet itself, so the registry path needs nothing here. @unmap/next leaves that to you, because a package that injects global CSS is a package you cannot control the cascade of. Import it once in the root layout.

// app/layout.tsx
import type { ReactNode } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Why no "use client" in your page

Both components are marked as client boundaries, so importing one into a Server Component is the same as importing any other client component. Next renders the surrounding page on the server, sends the map down as a client island, and mounts it in the browser.

The boundary has one consequence worth knowing. Functions cannot cross it, so a callback like onReady has to be passed from a Client Component:

// app/map-with-marker.tsx
"use client";
 
import { Map } from "@unmap/next";
import { maplibregl } from "@unmap/sdk";
 
export default function MapWithMarker() {
  return (
    <Map
      apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY!}
      center={[-114.07, 51.05]}
      zoom={11}
      onReady={(map) => {
        new maplibregl.Marker().setLngLat([-114.07, 51.05]).addTo(map);
      }}
    />
  );
}

On the registry path you would not write that at all: marker-popup gives you <Marker> as a child of <Map>, which needs no callback.

Where the key lives

The prop is apiKey here and key in Vue. React's useUnmap renames the SDK's key to apiKey; Vue's composable keeps key. Carrying the React name into a Vue file gets an unauthenticated map and a 401. See Add a map to Vue.

NEXT_PUBLIC_ variables are inlined into the browser bundle, which is correct here and worth being clear-eyed about: a key used by a map in a browser is readable by anyone who loads the page. That is true of every maps vendor. Restrict the key to your production origins in the dashboard rather than trying to hide it.

# .env.local
NEXT_PUBLIC_UNMAP_KEY=um_live_...

Server-side calls are different. A route handler geocoding an address should read an unrestricted key from a plain UNMAP_API_KEY, never from a NEXT_PUBLIC_ one.

Common problems

Every tile and request 401s or 403s, with a key you know is good. The key has an allowed-origins list and this origin is not on it: http://localhost:5173 is not the same origin as http://localhost:3000. See Authentication for how the list is matched.

The map is a blank box. The container has no height. Both components fill their parent and fall back to a 320px minimum, so give the parent a real height.

No zoom buttons and no attribution, on the package path. The stylesheet import is missing from the layout. The copied component does this for you.

A callback prop throws about crossing the boundary. It is being passed from a Server Component. Move that piece into a file with "use client" at the top, as above.