Skip to content

proj4 in a web app: the coordinate-system tax you only notice when a layer drifts 200m

3/22/2025Web DevelopmentGeospatial Urban Analytics Platform15 min read

proj4 in a web app: the coordinate-system tax you only notice when a layer drifts 200m

The first sign was not an error. Errors are kind. They give you a stack trace and a filename. This was worse — the map rendered, every feature was clickable, the legend lined up, the tooltips made sense. It just was not where it should have been.

A roads layer sat roughly two hundred metres east of the districts polygons it was supposed to overlay. Not so far that it looked broken, but far enough that any sanity check using a known intersection failed. We had been deploying this dashboard for weeks before someone with local knowledge zoomed in and said "that road does not go through that neighbourhood."

That is the coordinate-system tax. You pay it the day you notice — usually long after the bug was introduced — and the bill is roughly proportional to how confident you were in the layer.

This post is about how a Next.js + MapLibre app ended up with a 200-metre offset, how the fix lives in a single proj4 call, and the rule I now apply before any geospatial dataset enters a web project.

The situation

The app is a Next.js 15 viewer for federated geospatial data — districts, transport lines, analysis layers — on top of MapLibre GL. Data arrives from three sources: a GIS team's exports (UTM, metres), open data feeds (WGS84, degrees), and the user's own uploads (anyone's guess). The browser side needs all of it in EPSG:4326, because that is what MapLibre's GeoJSONSource expects.

The dependency that does the heavy lifting is unremarkable:

{
  "dependencies": {
    "proj4": "^2.19.5"
  }
}

proj4 is the same library you find under pyproj, under gdal, under most desktop GIS tools. It is roughly twenty kilobytes of mathematical history. The problem is not the library. The problem is that nothing in a typical web stack warns you when you forget to use it.

What you thought going in

The mental model I had when this project started was: GeoJSON is GeoJSON, MapLibre takes GeoJSON, MapLibre puts it on the map. The CRS question felt like a problem for the GIS team to solve before the file reaches me. When the first dataset opened in the browser and landed on the right country, I marked the box and moved on.

That worked because the first dataset was already in EPSG:4326. The team exporting it had remembered. Every subsequent dataset borrowed credibility from that first one.

The thing nobody on a web team tells you is that GeoJSON, the format, has only a soft answer to "what coordinate system is this." The 2008 spec said you could declare a CRS at the top of the document. The 2016 RFC 7946 said: do not bother, assume WGS84. So most files in the wild carry no CRS marker. The coordinates are just numbers. 656712.22, 2716847.97 and 46.67, 24.55 are both legal — but the first is metres in UTM Zone 38N and the second is degrees in WGS84. Plot the first one as if it were WGS84 and it lands in the wrong ocean.

The 200-metre case is the more dangerous version. The coordinates were already in WGS84 — but processed through a tooling chain that silently rounded, re-encoded, and reprojected through Web Mercator and back. Each step gave up a few centimetres until they added up.

What actually happened

We had a small utility for one specific dataset — metro line geometries that arrived in UTM Zone 38N from the GIS team. Someone wrote the conversion as a one-shot helper:

// helper/UTM38NtoLonLat.ts
import proj4 from "proj4";

export function UTM38NtoLonLat(utm: [number, number, number]): [number, number, number] {
  const [lon, lat] = proj4("EPSG:32638", "EPSG:4326", [utm[0], utm[1]]);
  return [lon, lat, utm[2]];
}

This is correct. UTM Zone 38N is EPSG:32638. WGS84 is EPSG:4326. proj4 knows both definitions by default. The helper preserves the Z value, which mattered for one particular elevation overlay.

That helper got called from the API route that served the metro lines GeoJSON:

// app/api/geojson/metro-lines/route.ts
import proj4 from 'proj4'

export async function GET() {
  const fileContents = await fs.readFile(
    jsonDirectory + '/Metrolines___JSO_FeaturesToJSO.geojson', 'utf8'
  )
  const geojsonData = JSON.parse(fileContents)

  const convertedFeatures = geojsonData.features.map((feature: any) => {
    if (feature.geometry.type === 'LineString') {
      const convertedCoordinates = feature.geometry.coordinates.map((coord: number[]) => {
        const [lon, lat] = proj4('EPSG:32638', 'EPSG:4326', [coord[0], coord[1]])
        return [lon, lat]
      })
      // ... replace coordinates
    }
    // ...
  })

  return NextResponse.json({ ...geojsonData, features: convertedFeatures })
}

So far so honest. The metro lines came up in the right place. We moved on.

Then user uploads landed. The product needed to accept arbitrary GeoJSON from the user — surveyors handing over a .geojson file, planners dragging in a layer from a desktop tool, anyone. The same coordinates problem now had to be solved on the read path, in the browser, against files we had never seen.

The detector we built was a heuristic on the coordinate bounds:

// components/viewer/.../GeoJSONUpload/utils/coordinateConverter.ts
export function detectCoordinateSystem(
  geojson: GeoJSON.FeatureCollection
): CoordinateSystem | null {
  const crs = (geojson as any).crs;
  if (crs && crs.type === 'name' && crs.properties && crs.properties.name) {
    const crsCode = crs.properties.name;
    if (COMMON_CRS[crsCode]) return COMMON_CRS[crsCode];
  }

  const coordinates = extractAllCoordinates(geojson);
  if (coordinates.length === 0) return null;
  const bounds = calculateBounds(coordinates);

  if (isWGS84Range(bounds)) return COMMON_CRS['EPSG:4326'];
  if (isWebMercatorRange(bounds)) return COMMON_CRS['EPSG:3857'];
  if (isUTMRange(bounds)) {
    const utmZone = detectUTMZone(coordinates);
    if (utmZone) {
      const utmCode = `EPSG:326${utmZone.toString().padStart(2, '0')}`;
      if (COMMON_CRS[utmCode]) return COMMON_CRS[utmCode];
    }
    return COMMON_CRS['EPSG:32638'];
  }
  return COMMON_CRS['EPSG:4326'];
}

Three branches. First, trust an explicit CRS tag if the file has one. Second, look at the numeric range — if every coordinate fits inside [-180, 180] x [-90, 90], treat it as WGS84. Third, if numbers are in the 100,000–1,000,000 range, treat as UTM and guess a zone. Fall back to WGS84.

This is the spot where the 200-metre bug lived. A specific dataset arrived in coordinates that looked exactly like WGS84 — small numbers, fits the lat/lon range — but had been pre-processed by an upstream tool that rounded to four decimal places. Four decimal places of longitude at our latitude is roughly eleven metres of resolution. The dataset had also been reprojected once through Web Mercator and back, which at certain latitudes introduced a systematic shift. The result was a layer that passed every automatic CRS detection and still landed two hundred metres east of where it belonged.

The bug was not "we forgot to call proj4." The bug was "the file said it was WGS84, our detector agreed, and the data was wrong before it ever reached us." Detection caught nothing because there was nothing to detect. The numbers were inside the WGS84 box. They were just incorrect numbers.

We found it by overlaying the upload against a known-good roads layer and walking a single junction. The visible offset on the screen, measured in tile pixels at zoom 16, came out to about 1.4 pixels — barely a brush stroke. Translated into ground distance at that zoom, it was the 200 metres.

The actual fix

The fix has two parts and neither of them is in proj4.

The first part is to stop trusting the file. When a dataset is supposed to be authoritative — districts, infrastructure, anything we will style and brand — we now require it to come with a sidecar metadata file declaring the source CRS, the source tool that produced it, and a hash of the raw export. proj4 still does the conversion, but the conversion runs against a CRS we believe in, not one we inferred:

// types.ts — the canonical CRS table the app actually uses
export const COMMON_CRS: Record<string, CoordinateSystem> = {
  'EPSG:4326': {
    code: 'EPSG:4326',
    name: 'WGS 84',
    proj4: '+proj=longlat +datum=WGS84 +no_defs',
    bounds: { minX: -180, minY: -90, maxX: 180, maxY: 90 }
  },
  'EPSG:3857': {
    code: 'EPSG:3857',
    name: 'Web Mercator',
    proj4: '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs',
    bounds: { minX: -20037508.34, minY: -20037508.34, maxX: 20037508.34, maxY: 20037508.34 }
  },
  'EPSG:32638': {
    code: 'EPSG:32638',
    name: 'UTM Zone 38N',
    proj4: '+proj=utm +zone=38 +datum=WGS84 +units=m +no_defs',
    bounds: { minX: 166021.44, minY: 0, maxX: 833978.56, maxY: 9329005.18 }
  }
};

Three entries cover ninety-five percent of the data this app sees. Each one is locked to a proj4 string we have verified against an authoritative source — epsg.io for the EPSG codes, the +nadgrids=@null flag on Web Mercator because most web tools omit datum shifts.

The second part of the fix is a no-op when the data is already correct, and a hard refusal when it is not. The upload route now branches on the detected CRS code and runs convert-then-validate. The validate step is small but did not exist before:

// app/api/upload/geojson/route.ts
const detectedCRS = detectCoordinateSystem(parsedData);
if (!detectedCRS) {
  return NextResponse.json(
    { success: false, error: 'Could not detect coordinate system' },
    { status: 400 }
  );
}

let convertedData = parsedData;
let conversionApplied = false;

if (detectedCRS.code !== 'EPSG:4326') {
  const targetCRS = COMMON_CRS['EPSG:4326'];
  const conversionResult = convertCoordinates(parsedData, detectedCRS, targetCRS);
  if (!conversionResult.success) {
    return NextResponse.json(
      { success: false, error: 'Coordinate conversion failed', details: conversionResult.error },
      { status: 400 }
    );
  }
  convertedData = conversionResult.data!;
  conversionApplied = true;
}

The response always carries the detected CRS and a conversionApplied flag. That flag now ends up in the layer metadata in MapLibre, surfaced in the layer panel. If a layer is wrong, the user can see what coordinate system the system thought it was looking at and override it. That alone has caught two more silent reprojections since we shipped it.

The hidden trap in this conversion is the geometry tree. GeoJSON allows nested geometries — MultiPolygon is an array of polygons, each an array of rings, each an array of points. The original implementation flattened too aggressively and dropped Z values. The corrected walker is verbose but boring, which is what you want here:

function convertGeometry(
  geometry: GeoJSON.Geometry,
  sourceCRS: CoordinateSystem,
  targetCRS: CoordinateSystem
): GeoJSON.Geometry {
  switch (geometry.type) {
    case 'Point':
      return { ...geometry, coordinates: convertPoint(geometry.coordinates, sourceCRS, targetCRS) };
    case 'LineString':
    case 'MultiPoint':
      return {
        ...geometry,
        coordinates: geometry.coordinates.map(c => convertPoint(c, sourceCRS, targetCRS))
      };
    case 'Polygon':
    case 'MultiLineString':
      return {
        ...geometry,
        coordinates: geometry.coordinates.map(ring =>
          ring.map(c => convertPoint(c, sourceCRS, targetCRS)))
      };
    case 'MultiPolygon':
      return {
        ...geometry,
        coordinates: geometry.coordinates.map(polygon =>
          polygon.map(ring => ring.map(c => convertPoint(c, sourceCRS, targetCRS))))
      };
    case 'GeometryCollection':
      return {
        ...geometry,
        geometries: geometry.geometries.map(g => convertGeometry(g, sourceCRS, targetCRS))
      };
    default:
      return geometry;
  }
}

A single missing case here is enough to render a partially-converted layer — half in metres, half in degrees — which produces the same kind of visible offset that started this whole investigation.

The lesson, stated as a rule you would apply tomorrow

Treat the coordinate system as part of the schema, not as a property of the data.

A row in your database has a type. A column in your API contract has a type. A coordinate has a CRS, and that CRS is type information just like number versus string is type information. If you would not accept an API payload without a content-type header, do not accept a geospatial dataset without a CRS declaration. If the declaration is missing, your code refuses or quarantines — it does not guess.

A few practical corollaries that fell out of this:

One — keep the CRS list small and explicit. Three entries (WGS84, Web Mercator, the regional UTM zone) handled almost every dataset the app saw. Adding a fourth was a deliberate decision with a paper trail. The COMMON_CRS table above is twenty lines of code and it is the most expensive twenty lines in the project to get wrong.

Two — log the conversion. Every reprojection writes a record: source CRS, target CRS, feature count, file hash. When the visible offset complaint comes in three months later, that log is how you find which export contaminated the system.

Three — verify against a known-good landmark. Pick a point in the rendered area you can identify in the real world — a major intersection, a roundabout, a building footprint. Have a CI check that any new dataset, after conversion, places that landmark within an acceptable tolerance of its known coordinates. The tolerance for this app is twenty metres at the equator-adjusted level; that catches the 200-metre case but does not flag legitimate centimetre-scale drift that we cannot do anything about.

Four — distrust visual confirmation by itself. The map looked right for weeks. "It rendered" is not proof that the coordinates are correct. The data was wrong while the rendering was honest.

Where the lesson does not apply

If your app only ever shows base tiles plus a handful of geocoded points from a service that already returns WGS84, you do not need any of this. The proj4 dependency is a meaningful bundle cost — about twenty kilobytes minified — and the detector code is a maintenance surface. A pin-on-a-map app should not import proj4 just to be safe. It should pick one CRS, document it in the README, and refuse anything else.

The detector heuristic also breaks when you start working with national grids that share coordinate ranges with UTM — British National Grid, the Turkish ITRF-based grid, Indian Lambert zones. At that point bounds-checking is not enough. You will need either an explicit declaration (preferred) or a more careful inference using proj4's reverse projection — convert tentatively, check whether the result lands inside the country, fall back. We did not need that yet; if we did, the same detector would need rewriting.

And if you can constrain inputs upstream — a pipeline you control where the GIS team commits to delivering WGS84 only — do that first. Reprojection in the browser is a tax. Not paying it is cheaper than paying it well.

Trade-off

The trade-off the rule accepts is friction at the file-acceptance step. Users uploading their own layer have to wait for detection, and if it fails, they get an error and have to reformat. That is annoying. It is also the only mechanism that catches the dataset that lies about itself. A faster app that silently displays the wrong location is worse than a slower app that refuses to display until the location is provable.

The proj4 conversion itself has a tax of its own: a single point conversion is roughly a microsecond, but the upload path can handle hundred-thousand-feature files, and that is hundreds of milliseconds of synchronous JavaScript on the main thread if you do it client-side. We push the conversion server-side in the route handler, which costs a round trip but keeps the browser responsive. There is no free lane here — pick the cost.

Business impact

A 200-metre offset on a planning dashboard is not a UI bug. If a decision is made off a map — where to route a road, where to place infrastructure, which districts are affected by a service — and the layer is two hundred metres east of the truth, the decision is wrong. The credibility hit is bigger than the technical hit. Once a stakeholder catches one offset, every layer in the system is suspect, including the layers that are correct. The cost of the audit that follows is larger than the cost of building the original detection correctly. So treat coordinate systems the way you treat money handling — not as an engineering concern, as a trust concern.

What to do next

Open the project you ship maps from. Find every place a coordinate enters the system. For each one, answer two questions in writing: which CRS does this arrive in, and how would I know if that answer were wrong? If you cannot answer the second question, you have not actually paid the tax — you have just deferred it. The bill always comes due, usually in a meeting, usually from someone who knows the city better than you do.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox