DEV Community

Cover image for Series 2 Google Maps Clone: With Next.js 16 + OpenLayers + TypeScript — A Modern Map Starter Kit
Welly Wahyudi
Welly Wahyudi

Posted on

Series 2 Google Maps Clone: With Next.js 16 + OpenLayers + TypeScript — A Modern Map Starter Kit

Why I Built This (Again)

A few weeks ago, I released my Next.js + Leaflet starter kit as the first step in a mapping starter-kit series I’ve been planning. After wrapping that one up, the next logical piece was OpenLayers—especially for developers working on more advanced GIS workflows.

Leaflet is great for lightweight, interactive maps, but many GIS-driven projects need features like vector tiles, custom projections (EPSG:3857 isn’t always enough), high-precision geometry rendering, advanced interactions, and performant handling of large GeoJSON datasets. That’s where OpenLayers really shines.

So I rebuilt the entire starter from the ground up—same UI, same structure, same developer experience—but powered by OpenLayers to support real GIS use cases.

This isn’t a replacement for the Leaflet version.
It’s simply the next step in the series for developers who need a more capable, GIS-focused mapping engine.

And once this OpenLayers kit is out, I’ll be completing the lineup with a Next.js + MapLibre starter kit, designed for modern vector-tile–driven maps and 3D visualizations.


What I Built

A production-ready Next.js 16 starter with vanilla OpenLayers and the same Google Maps-inspired UI from my Leaflet starter.

NextJS OpenLayers Demo

The Stack

Technology Version Purpose
Next.js 16 App Router, Server Components
React 19 UI Framework
OpenLayers 10 Mapping (vanilla, no wrapper)
TypeScript 5 Type Safety
Tailwind CSS 4 Styling
shadcn/ui Latest Accessible UI Components

What's Included

This isn't a minimal example. It's a full-featured starter with everything I needed when building real map apps.

Core Features

  • Base map setup — OpenLayers Map and View with proper initialization
  • Multiple tile providers — OpenStreetMap, Satellite, Dark mode
  • Theme-aware tiles — Auto-switches based on light/dark mode
  • GeoJSON rendering — With custom styling and fit-to-bounds
  • Country search — Debounced search with keyboard navigation
  • Custom markers — Add markers anywhere with popups
  • Context menu — Right-click to copy coordinates, add markers, measure
  • Measurement tools — Distance and area with interactive drawing
  • POI management — Full CRUD with 14 categories, localStorage persistence
  • Geolocation — Find user location with accuracy circle
  • Responsive layout — Mobile drawer, desktop sidebar
  • Error boundaries — Graceful error handling
  • Dark mode — Full theme support

What Makes It Different

1. Coordinate conversion utilities

OpenLayers uses [lng, lat] with projections. The rest of the world uses [lat, lng]. I built utilities to handle this:

import { latLngToOL, olToLatLng } from "@/lib/utils/coordinates";

// Convert [lat, lng] to OpenLayers coordinate
const olCoord = latLngToOL([51.505, -0.09]);

// Convert back
const latLng = olToLatLng(olCoord);
Enter fullscreen mode Exit fullscreen mode

2. Proper layer management

OpenLayers requires explicit layer add/remove. I built hooks that handle this:

const { tileProvider } = useMapTileProvider();

<OpenLayersTileLayer provider={tileProvider} />;
Enter fullscreen mode Exit fullscreen mode

3. Memory leak prevention

OpenLayers needs manual cleanup. Every component properly disposes of resources:

useEffect(() => {
  // ... map initialization

  return () => {
    map.setTarget(undefined); // Detach from DOM
    map.dispose(); // Dispose of resources
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Code Examples

Basic Map Setup

"use client";

import { MapProvider } from "@/contexts/MapContext";
import { OpenLayersMap, OpenLayersTileLayer } from "@/components/map";

export default function MapPage() {
  return (
    <MapProvider>
      <OpenLayersMap center={[51.505, -0.09]} zoom={13}>
        <OpenLayersTileLayer provider={osmProvider} />
      </OpenLayersMap>
    </MapProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Adding GeoJSON

import { OpenLayersGeoJSON } from "@/components/map";

<OpenLayersGeoJSON
  data={countryData}
  style={{
    color: "#2563eb",
    fillColor: "#3b82f6",
    fillOpacity: 0.2,
  }}
  fitBounds={true}
/>;
Enter fullscreen mode Exit fullscreen mode

Using Map Controls

import { useMapControls } from "@/hooks/useMapControls";

function MapControls() {
  const { zoomIn, zoomOut, resetView, flyTo } = useMapControls();

  return (
    <div>
      <button onClick={zoomIn}>Zoom In</button>
      <button onClick={zoomOut}>Zoom Out</button>
      <button onClick={resetView}>Reset</button>
      <button onClick={() => flyTo([51.505, -0.09], 13)}>Fly to London</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Managing POIs

import { usePOIManager } from "@/hooks/usePOIManager";

function POIPanel() {
  const { pois, addPOI, deletePOI, exportGeoJSON } = usePOIManager();

  const handleAdd = () => {
    addPOI("Coffee Shop", 51.505, -0.09, "food-drink", "Great espresso");
  };

  return (
    <div>
      <button onClick={handleAdd}>Add POI</button>
      <button onClick={exportGeoJSON}>Export</button>
      <ul>
        {pois.map((poi) => (
          <li key={poi.id}>
            {poi.title}
            <button onClick={() => deletePOI(poi.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Measurement Tools

import { useMeasurement } from "@/hooks/useMeasurement";

function MeasurementPanel() {
  const { startMeasurement, clearMeasurement, distance, area } =
    useMeasurement();

  return (
    <div>
      <button onClick={() => startMeasurement("distance")}>
        Measure Distance
      </button>
      <button onClick={() => startMeasurement("area")}>Measure Area</button>
      <button onClick={clearMeasurement}>Clear</button>

      {distance > 0 && <p>Distance: {(distance / 1000).toFixed(2)} km</p>}
      {area > 0 && <p>Area: {(area / 1000000).toFixed(2)} km²</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Screenshots

Main Map Interface

Main Map Interface

POI Management Panel

POI Management Panel

Measurement Tools

Measurement Tools

Mobile Responsive

Mobile Responsive Map


Getting Started

The repo is open source (MIT license):

🔗 GitHub: github.com/wellywahyudi/nextjs-openlayers-starter

Installation

# Clone the repository
git clone https://github.com/wellywahyudi/nextjs-openlayers-starter.git
cd nextjs-openlayers-starter

# Install dependencies
npm install

# Start development server
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000/map and you're ready to go.

Customizing the Default View

Edit constants/map-config.ts:

export const DEFAULT_MAP_CONFIG: MapConfig = {
  defaultCenter: [51.505, -0.09], // [lat, lng]
  defaultZoom: 13,
  minZoom: 3,
  maxZoom: 18,
};
Enter fullscreen mode Exit fullscreen mode

Adding Custom Tile Providers

Edit constants/tile-providers.ts:

export const TILE_PROVIDERS: TileProvider[] = [
  {
    id: "custom",
    name: "My Custom Tiles",
    url: "https://your-tile-server/{z}/{x}/{y}.png",
    attribution: "© Your Attribution",
    maxZoom: 19,
    category: "standard",
  },
  // ...existing providers
];
Enter fullscreen mode Exit fullscreen mode

Who Should Use This?

This starter is for you if:

  • 🗺️ You're building a GIS application — spatial analysis, custom projections, vector tiles
  • 📊 You need to render large datasets — 10,000+ features, complex geometries
  • 🎯 You want advanced interactions — drawing, editing, snapping, measurement
  • 🚀 You're prototyping a map-heavy app — dashboards, analytics, visualization
  • 📚 You're learning OpenLayers — and want a clean, modern starting point

If you just need a simple map with markers, stick with the Leaflet version. It's simpler.

If you need power and flexibility, this is for you.


OpenLayers-Specific Notes

Coordinate Systems

OpenLayers uses EPSG:3857 (Web Mercator) by default and expects [lng, lat] order. The starter handles conversion automatically:

// External API uses [lat, lng]
const userInput = [51.505, -0.09];

// Convert to OpenLayers format
const olCoord = latLngToOL(userInput);

// Use with OpenLayers
map.getView().setCenter(olCoord);
Enter fullscreen mode Exit fullscreen mode

Layer Stack

Layers are rendered in order (bottom to top):

  1. Base Tile Layer
  2. GeoJSON Vector Layer
  3. POI Vector Layer
  4. Marker Vector Layer
  5. Measurement Vector Layer

Tree-Shaking

OpenLayers is fully tree-shakeable. Import only what you need:

// ✅ Good - tree-shakeable
import Map from "ol/Map";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";

// ❌ Bad - imports everything
import * as ol from "ol";
Enter fullscreen mode Exit fullscreen mode

Bundle Size

  • Leaflet version: ~50KB
  • OpenLayers version: ~90KB

The increase is worth it for the features you get.


What's Next?

I'm not done. Here's what I'm planning:

Short-term

I'm building a complete series of Next.js mapping starters:

Starter Status
Next.js + Leaflet ✅ Available
Next.js + OpenLayers ✅ Available
Next.js + MapLibre GL 🚧 Planned

Same UI, same DX, different mapping libraries. Pick the one that fits your project.


Contributing

This is open source. If you find bugs, have ideas, or want to contribute:

The codebase is clean, documented, and beginner-friendly.


Final Thoughts

This starter took weeks to build. I migrated every component from Leaflet to OpenLayers, rewrote coordinate handling, fixed memory leaks, and tested on mobile.

It's not perfect. There are edge cases I haven't hit yet. But it's solid, it's fast, and it solves real problems.

If you're building something with OpenLayers and Next.js, this will save you days of setup. If it helps you ship faster, that's all I wanted.

And if you build something cool with it, I'd love to see it. Drop a link in the comments.

A ⭐ on GitHub would mean a lot.

Happy mapping! 🗺️✨


Links

Top comments (0)