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

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

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.