Working with GeoJSON in JavaScript

Parse transform and render GeoJSON in JavaScript with Turf.js Leaflet Mapbox GL and TypeScript types.

Parsing GeoJSON in JavaScript

GeoJSON is JSON — there is no special parser required. JSON.parse() and fetch() work out of the box. The only GeoJSON-specific handling you need is validating the structure after parsing.

javascript// Parse from a string
const geojson = JSON.parse(rawString);

// Fetch from a URL
async function loadGeoJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  const data = await response.json();
  if (data.type !== "FeatureCollection" && data.type !== "Feature") {
    throw new Error("Response is not valid GeoJSON");
  }
  return data;
}

// Usage
loadGeoJSON("https://example.com/stores.geojson")
  .then(data => console.log(`Loaded ${data.features.length} features`))
  .catch(err => console.error("Failed to load GeoJSON:", err));

Always check response.ok before calling response.json(). A 404 response returns a valid HTTP response but the body will be an error page, not JSON — calling .json() on it throws a SyntaxError that obscures the real problem. Use the GeoJSON Validator to check your data structure before writing parsing code.

TypeScript Types for GeoJSON

Install the community type definitions with npm install --save-dev @types/geojson. These ship with precise discriminated union types for all 7 geometry types.

javascriptimport type {
  FeatureCollection,
  Feature,
  Geometry,
  Point,
  LineString,
  Polygon,
  GeoJsonProperties,
} from "geojson";

// A typed FeatureCollection of point features
type StoreCollection = FeatureCollection<Point, { name: string; address: string }>;

// A generic feature with any geometry
type AnyFeature = Feature<Geometry, GeoJsonProperties>;

// Narrow geometry type using discriminated union
function handleGeometry(geometry: Geometry) {
  switch (geometry.type) {
    case "Point":
      // geometry.coordinates is [number, number] | [number, number, number]
      const [lng, lat] = geometry.coordinates;
      console.log(`Point at ${lng}, ${lat}`);
      break;
    case "Polygon":
      // geometry.coordinates is number[][][]
      console.log(`Polygon with ${geometry.coordinates[0].length} vertices`);
      break;
    case "LineString":
      console.log(`Line with ${geometry.coordinates.length} points`);
      break;
  }
}

// Function that accepts any FeatureCollection
function countFeatures(fc: FeatureCollection): number {
  return fc.features.length;
}

The Geometry union type covers all 7 geometry variants: Point | MultiPoint | LineString | MultiLineString | Polygon | MultiPolygon | GeometryCollection. TypeScript will enforce coordinate shape — Point.coordinates is typed as Position (a number[] of length 2 or 3), while Polygon.coordinates is Position[][].

Turf.js for Spatial Analysis

Turf.js is a modular spatial analysis library with over 100 functions. Install the full package with npm install @turf/turf or install individual modules like npm install @turf/buffer to minimize bundle size. Turf runs in both the browser and Node.js and operates entirely on GeoJSON.

Buffer — Expand a Feature by Distance

javascriptimport * as turf from "@turf/turf";

const point = turf.point([-73.9857, 40.7484]);

// Create a 500-meter buffer around the point
const buffered = turf.buffer(point, 0.5, { units: "kilometers" });
// buffered is a GeoJSON Feature<Polygon>

// Buffer a line by 100 meters on each side
const road = turf.lineString([[-73.99, 40.73], [-73.98, 40.74], [-73.97, 40.75]]);
const corridor = turf.buffer(road, 0.1, { units: "kilometers" });

The online GeoJSON Buffer tool does this visually without code.

Centroid — Find the Center of a Feature

javascriptimport { centroid, centerOfMass } from "@turf/turf";

const polygon = turf.polygon([[
  [-73.9876, 40.7661],
  [-73.9814, 40.7661],
  [-73.9814, 40.7623],
  [-73.9876, 40.7623],
  [-73.9876, 40.7661],
]]);

// Geometric centroid (average of vertices)
const c1 = centroid(polygon);
// [lng, lat] of centroid: [-73.9845, 40.7642]

// Center of mass (weighted, better for irregular shapes)
const c2 = centerOfMass(polygon);

Use the Centroid Calculator to inspect centroids visually.

booleanPointInPolygon — Spatial Filter

javascriptimport { booleanPointInPolygon, point, polygon } from "@turf/turf";

const nyc = polygon([[
  [-74.259, 40.477],
  [-73.700, 40.477],
  [-73.700, 40.917],
  [-74.259, 40.917],
  [-74.259, 40.477],
]]);

const empireSt = point([-73.9857, 40.7484]);
const london = point([-0.1276, 51.5074]);

console.log(booleanPointInPolygon(empireSt, nyc)); // true
console.log(booleanPointInPolygon(london, nyc));   // false

// Filter a FeatureCollection to points inside a boundary
function filterPointsInside(points, boundary) {
  return {
    type: "FeatureCollection",
    features: points.features.filter(f =>
      booleanPointInPolygon(f, boundary)
    ),
  };
}

area, length, and along

javascriptimport { area, length, along } from "@turf/turf";

// Area in square meters
const sqMeters = area(polygon);
const sqKm = sqMeters / 1_000_000;
console.log(`Area: ${sqKm.toFixed(2)} km²`);

// Length of a LineString in kilometers
const trail = turf.lineString([[-73.99, 40.73], [-73.97, 40.75], [-73.95, 40.76]]);
const km = length(trail, { units: "kilometers" });
console.log(`Trail length: ${km.toFixed(2)} km`);

// Point 1.5km along a line
const midpoint = along(trail, 1.5, { units: "kilometers" });
// midpoint is a GeoJSON Feature<Point>

Rendering GeoJSON with Leaflet

Leaflet's L.geoJSON() layer accepts any valid GeoJSON object and renders Points as markers, LineStrings as polylines, and Polygons as filled shapes. The layer fires a layeradd event for each feature, letting you attach popups and custom styles.

javascript// npm install leaflet
// npm install --save-dev @types/leaflet
import L from "leaflet";
import "leaflet/dist/leaflet.css";

const map = L.map("map").setView([40.7484, -73.9857], 13);

L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
  attribution: "© OpenStreetMap contributors",
}).addTo(map);

const geojsonLayer = L.geoJSON(data, {
  // Style function receives each Feature
  style(feature) {
    return {
      color: feature.properties.category === "park" ? "#2d6a4f" : "#e76f51",
      weight: 2,
      fillOpacity: 0.4,
    };
  },

  // Called for each feature; add popups here
  onEachFeature(feature, layer) {
    if (feature.properties?.name) {
      layer.bindPopup(`<strong>${feature.properties.name}</strong>`);
    }
  },

  // Convert Point features to circle markers instead of default icons
  pointToLayer(feature, latlng) {
    return L.circleMarker(latlng, {
      radius: 8,
      fillColor: "#457b9d",
      fillOpacity: 0.9,
      color: "#1d3557",
      weight: 1,
    });
  },
}).addTo(map);

// Fit map to the GeoJSON extent
map.fitBounds(geojsonLayer.getBounds());

The style function runs once per feature, so you can drive visual encoding entirely from properties — no separate CSS needed. For clustering, add leaflet.markercluster as a wrapper around the GeoJSON layer.

Rendering GeoJSON with Mapbox GL JS / MapLibre

Mapbox GL JS and MapLibre GL JS share the same API for sources and layers. Add GeoJSON as a source, then reference it in one or more layer definitions. A single source can back multiple layers — for example, a fill layer for the polygon interior and a line layer for the outline.

javascript// npm install maplibre-gl  (or mapbox-gl for Mapbox)
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

const map = new maplibregl.Map({
  container: "map",
  style: "https://demotiles.maplibre.org/style.json",
  center: [-73.9857, 40.7484],
  zoom: 12,
});

map.on("load", () => {
  // Add GeoJSON data as a source
  map.addSource("neighborhoods", {
    type: "geojson",
    data: "https://example.com/nyc-neighborhoods.geojson",
    // For large files, enable clustering:
    // cluster: true,
    // clusterMaxZoom: 14,
    // clusterRadius: 50,
  });

  // Fill layer for polygon interiors
  map.addLayer({
    id: "neighborhoods-fill",
    type: "fill",
    source: "neighborhoods",
    paint: {
      "fill-color": [
        "interpolate", ["linear"],
        ["get", "population"],
        0, "#f1eef6",
        50000, "#980043",
      ],
      "fill-opacity": 0.6,
    },
  });

  // Line layer for polygon outlines
  map.addLayer({
    id: "neighborhoods-outline",
    type: "line",
    source: "neighborhoods",
    paint: {
      "line-color": "#333",
      "line-width": 1,
    },
  });

  // Click handler to show feature properties
  map.on("click", "neighborhoods-fill", (e) => {
    const props = e.features[0].properties;
    new maplibregl.Popup()
      .setLngLat(e.lngLat)
      .setHTML(`<strong>${props.name}</strong><br>Pop: ${props.population?.toLocaleString()}`)
      .addTo(map);
  });
});

The data-driven interpolate expression in the fill color maps a numeric property to a color ramp at render time — no preprocessing required. MapLibre supports the full Mapbox Style Spec expression language and is fully open-source (MIT license).

Web Workers for Large GeoJSON Files

Parsing a GeoJSON file larger than 10 MB on the main thread blocks the UI for hundreds of milliseconds. A 50 MB FeatureCollection can freeze the browser for 2–4 seconds. Move the parse and any preprocessing into a Web Worker to keep the UI responsive.

javascript// geojson-worker.js
self.onmessage = async function (e) {
  const { url } = e.data;
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const text = await response.text();
    // JSON.parse blocks inside the worker, not the main thread
    const data = JSON.parse(text);
    self.postMessage({ status: "ok", data });
  } catch (err) {
    self.postMessage({ status: "error", message: err.message });
  }
};
javascript// main.js — spawn the worker and receive results
const worker = new Worker(new URL("./geojson-worker.js", import.meta.url));

worker.onmessage = (e) => {
  if (e.data.status === "error") {
    console.error("Worker error:", e.data.message);
    return;
  }
  const geojson = e.data.data;
  console.log(`Received ${geojson.features.length} features from worker`);
  renderMap(geojson);
  worker.terminate(); // free memory when done
};

worker.postMessage({ url: "https://example.com/large.geojson" });

In Vite, the new URL("./worker.js", import.meta.url) pattern is natively supported. In webpack 5, use the Worker constructor directly with the same syntax. For files over 100 MB, consider streaming with response.body.getReader() and a streaming JSON parser like clarinet or @streamparser/json.

Fetching GeoJSON from APIs

Three common GeoJSON data sources each have slightly different fetch patterns.

GitHub Raw Files

javascript// GitHub raw URLs bypass the HTML page and return the raw file
const url =
  "https://raw.githubusercontent.com/username/repo/main/data/cities.geojson";

const res = await fetch(url);
// GitHub sets Content-Type: text/plain for raw files — use .json() anyway
const data = await res.json();

Overpass API (OpenStreetMap Data)

javascript// Query all cafes in a bounding box and return as GeoJSON
async function fetchOSMCafes(south, west, north, east) {
  const query = `
    [out:json][timeout:25];
    node["amenity"="cafe"](${south},${west},${north},${east});
    out body;
  `;
  const res = await fetch("https://overpass-api.de/api/interpreter", {
    method: "POST",
    body: query,
  });
  const osm = await res.json();
  // Convert OSM JSON to GeoJSON manually (Overpass returns its own format)
  return {
    type: "FeatureCollection",
    features: osm.elements.map(el => ({
      type: "Feature",
      geometry: { type: "Point", coordinates: [el.lon, el.lat] },
      properties: el.tags,
    })),
  };
}

// Manhattan bounding box
fetchOSMCafes(40.700, -74.020, 40.880, -73.910)
  .then(fc => console.log(`Found ${fc.features.length} cafes`));

Government Open Data APIs

javascript// NYC Open Data — Socrata API returns GeoJSON natively with $limit param
const NYC_PARKS =
  "https://data.cityofnewyork.us/resource/enfh-gkve.geojson?$limit=500";

const parks = await fetch(NYC_PARKS).then(r => r.json());
// parks.features[0].geometry.type === "MultiPolygon"

// Many US federal datasets use ArcGIS REST endpoints
const FEDERAL_URL =
  "https://services.arcgis.com/.../query?where=1=1&outFields=*&f=geojson";
const federal = await fetch(FEDERAL_URL).then(r => r.json());

Node.js / Express GeoJSON API

The correct MIME type for GeoJSON is application/geo+json (registered with IANA in 2014). Many clients also accept application/json, but serving the correct type lets clients auto-detect the format.

javascript// npm install express
import express from "express";
import { readFileSync } from "fs";

const app = express();

// Serve a static GeoJSON file
app.get("/api/stores", (req, res) => {
  const data = readFileSync("./data/stores.geojson", "utf8");
  res.setHeader("Content-Type", "application/geo+json");
  res.setHeader("Cache-Control", "public, max-age=300"); // 5 min cache
  res.send(data);
});

// Build GeoJSON dynamically from a database
app.get("/api/stores/nearby", async (req, res) => {
  const { lat, lng, radius = 5 } = req.query;
  // Assume db.getStoresNearby returns rows with lat/lng columns
  const rows = await db.getStoresNearby(Number(lat), Number(lng), Number(radius));
  const fc = {
    type: "FeatureCollection",
    features: rows.map(row => ({
      type: "Feature",
      geometry: {
        type: "Point",
        coordinates: [row.lng, row.lat], // longitude FIRST
      },
      properties: {
        id: row.id,
        name: row.name,
        address: row.address,
      },
    })),
  };
  res.setHeader("Content-Type", "application/geo+json");
  res.json(fc);
});

app.listen(3000);

In PostgreSQL with PostGIS, use ST_AsGeoJSON(geom) to generate the geometry object directly in SQL, then assemble the Feature wrapper in JavaScript. This avoids coordinate conversion errors and is faster than doing it in application code.

Validation in Code

Two lightweight libraries handle GeoJSON validation: geojson-validation (npm package geojson-validation, 3 kB minzipped) and Turf's @turf/boolean-valid. Use geojson-validation for structural validation (is the JSON shaped correctly?) and Turf for geometric validity (do the coordinates form a valid shape?).

javascript// npm install geojson-validation
import gv from "geojson-validation";

const data = JSON.parse(rawInput);

if (gv.valid(data)) {
  console.log("Valid GeoJSON");
} else {
  const errors = gv.isFeatureCollection(data, true);
  console.error("Validation errors:", errors);
  // errors is an array of strings describing each problem
}

// Check specific geometry validity with Turf
import { booleanValid } from "@turf/turf";

for (const feature of data.features) {
  if (!booleanValid(feature)) {
    console.warn(`Invalid geometry for feature: ${feature.properties?.name}`);
  }
}

// Common issues caught by validation:
// - Polygon ring not closed (first !== last coordinate)
// - Coordinates in [lat, lng] order instead of [lng, lat]
// - Missing "type" property
// - Empty coordinates array

For interactive validation without writing code, paste your GeoJSON into the GeoJSON Validator. It runs the same structural checks and highlights the exact line where problems occur. The GeoJSON Viewer will also reveal coordinate order mistakes immediately — if your shapes appear in the ocean, your coordinates are probably swapped.