searoute-ts
Shortest sea route between any two points on Earth. A TypeScript / JavaScript
library for maritime route planning, port-to-port distance, ETA estimation, and
shipping-lane visualisation β powered by the 2025 Eurostat maritime network.

import { seaRoute } from 'searoute-ts';
const route = seaRoute([121.5, 31.0], [4.4, 51.9]);
πΊοΈ Try the interactive demo β click two points on a map and see the route, with all options live. (source)
Works from plain JavaScript too β the package ships compiled .js plus
.d.ts declarations. The -ts in the name is for searchability, not a
language requirement.
Why searoute-ts
- π’ Realistic shipping routes, not great-circle lines through Eurasia.
- πΊοΈ Returns GeoJSON β drop straight into Leaflet, Mapbox, deck.gl, MapLibre.
- π 2025 Eurostat marnet with explicit Suez, Panama, Bab-el-Mandeb,
Malacca, Gibraltar, Dover, Kiel, Corinth, Bering, Magellan, NW/NE Passage labels.
- π« Canal & strait restrictions β force Cape of Good Hope during a Red Sea
disruption with one option.
- π¦ Vessel-draft gating β auto-block Panama (15.2 m), Suez (20.1 m), Kiel
(7 m), Corinth (7.3 m) when the vessel exceeds the canal limit.
- π€οΈ K-shortest alternatives β
seaRouteAlternatives returns the baseline
plus up to N realistic alternatives.
- π§ Multi-leg waypoints β
seaRouteMulti for port rotations and itineraries.
- β±οΈ ETA from speed β
speedKnots β durationHours.
- π οΈ Modern toolchain β TypeScript 5, ESM + CJS dual build, types included,
Node 18+, zero peer deps.
Quick examples
Basic β shortest route
import { seaRoute } from 'searoute-ts';
const route = seaRoute([-74.04, 40.69], [-0.13, 51.5]);
With ETA and units
seaRoute(shanghai, rotterdam, {
units: 'kilometers',
speedKnots: 22,
});
Red Sea / Suez disruption β force Cape of Good Hope
seaRoute(shanghai, rotterdam, {
restrictions: ['suez', 'babelmandeb'],
});
Vessel-aware β Ultra Large Container Ship
seaRoute(shanghai, newYork, {
vesselDraftMeters: 16,
});
Port codes (UN/LOCODE)
import 'searoute-ts/ports';
import { seaRoute } from 'searoute-ts';
seaRoute('CNSHA', 'NLRTM');
seaRoute('CNSHA', [4.4, 51.9]);
The ~1 600-port dataset lives behind the searoute-ts/ports subpath so the core
stays lean β importing it registers the resolver. You can also resolve codes
yourself:
import { lookupPort, resolvePort } from 'searoute-ts/ports';
lookupPort('SGSIN');
resolvePort('SGSIN');
Unknown codes throw UnknownPortError. See Port codes below for provenance.
Load the port dataset from a CDN instead of bundling it
Don't want to bundle the ~135 KB dataset? Fetch it at runtime with loadPorts β
the analog of loadNetwork.
The dataset also ships as a raw dist/ports.json, so jsDelivr/unpkg serve it
versioned for free:
import { seaRoute, loadPorts } from 'searoute-ts';
await loadPorts('https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/ports.json');
seaRoute('CNSHA', 'NLRTM');
https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/ports.json # newest
https://cdn.jsdelivr.net/npm/searoute-ts@<version>/dist/ports.json # frozen/immutable
(dist/ports.json ships from the release that adds port codes onward β pin any
version at or after it for reproducibility.)
loadPorts registers the fetched dataset (so code strings resolve) and returns
it. It uses the global fetch (Node β₯18 / browsers); pass { fetch } to override.
Multi-leg / port rotation
import { seaRouteMulti } from 'searoute-ts';
seaRouteMulti(
[shanghai, singapore, mumbai, rotterdam],
{ units: 'kilometers', returnPassages: true },
);
Alternative routes (Yen-style canal permutation)
import { seaRouteAlternatives } from 'searoute-ts';
const alts = seaRouteAlternatives(shanghai, rotterdam, { k: 4 });
Fetch the network from a URL instead of bundling it (optional)
The network is bundled by default, so seaRoute works offline with zero setup.
If you'd rather not ship the ~1 MB network (e.g. to trim a browser bundle,
or to use an updated network without upgrading the package), fetch it at
runtime and pass it via the existing network option:
import { seaRoute, loadNetwork } from 'searoute-ts';
const network = await loadNetwork('https://mayurrawte.github.io/searoute-ts/marnet.json');
const route = seaRoute(shanghai, rotterdam, { network });
Only the fetch is async β seaRoute itself stays synchronous. loadNetwork
uses the global fetch (Node β₯18 and all browsers); pass { fetch } to supply
your own. This is purely opt-in; nothing changes if you don't use it.
Which option should I use?
| Approach | How | Data version | Works offline | Best for |
|---|
| Bundled (default) | seaRoute(a, b) β no network | pinned to your installed package | β
| Most users; zero config, deterministic |
| Latest via URL | loadNetwork('β¦/marnet.json') | always the newest hosted | β needs network | Always-current data without upgrading |
| Pinned via CDN | loadNetwork('https://cdn.jsdelivr.net/npm/searoute-ts@2.0.1/β¦') | frozen (immutable) | β needs network | Reproducible builds |
Versioning the hosted network
You choose the version by choosing the URL:
-
@latest / rolling β the GitHub Pages URL above always serves the current
network. Convenient, but it can change under you.
-
Pinned & immutable β because the package is on npm, jsDelivr and
unpkg serve every published version automatically, with immutable
per-version URLs:
https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/marnet.json # newest
https://cdn.jsdelivr.net/npm/searoute-ts@2/dist/marnet.json # newest 2.x
https://cdn.jsdelivr.net/npm/searoute-ts@2.0.1/dist/marnet.json # frozen
A pinned URL never changes, so your routes stay reproducible. (These
standalone-JSON CDN paths land with the package once the network ships as a
separate asset β see issue #10; until then, use the GitHub Pages URL.)
For production, prefer a pinned URL (or just the bundled default) so your
distances don't shift when the network is updated.
Higher-resolution networks (optional)
The bundled network is Eurostat's 100 km marnet_plus. Eurostat also
publishes finer resolutions, which give more accurate coastal routing and
shorter-hop fidelity at the cost of a larger download and slightly slower
first-route graph construction. Two moderate resolutions ship as subpath
exports so you only pay for them if you import them:
import { DEFAULT_MARNET } from 'searoute-ts/marnet-20km';
import { seaRoute } from 'searoute-ts';
seaRoute(origin, destination, { network: DEFAULT_MARNET });
Like the bundled default, each variant ships once as a shared
dist/data/marnet-<res>.cjs asset that both the CJS and ESM builds load at
runtime, so importing a variant doesn't duplicate the network across builds.
| Import | Resolution | Segments | JSON size | gzipped | Coastal accuracy |
|---|
searoute-ts (bundled default) | 100 km | 9,847 | ~1.3 MB | ~0.18 MB | Baseline β good for global routing |
searoute-ts/marnet-50km | 50 km | 15,498 | ~1.9 MB | ~0.27 MB | Modest step up |
searoute-ts/marnet-20km | 20 km | 29,581 | ~3.6 MB | ~0.51 MB | Noticeably finer coastal hops |
via loadNetwork (see below) | 10 km | 48,301 | ~5.9 MB | ~0.84 MB | High β larger download |
via loadNetwork (see below) | 5 km | 72,478 | ~9.0 MB | ~1.24 MB | Highest β largest download |
The 10 km and 5 km networks are large enough that bundling them would dominate
the install, so they are not shipped in the package. Generate them from the
Eurostat source with scripts/build-marnet.cjs (the script header documents the
GDAL conversion), host the resulting JSON, and load it with
loadNetwork
β or pass any FeatureCollection<LineString> to the network option directly.
Output shape
{
type: 'Feature',
geometry: { type: 'LineString', coordinates: [[lon, lat], ...] },
properties: {
length: number,
units: 'nauticalmiles' | 'kilometers' | 'miles' | ...,
bbox: [minLon, minLat, maxLon, maxLat],
greatCircleLength: number,
detourRatio: number,
originSnapKm: number,
destinationSnapKm: number,
durationHours?: number,
passages?: ('suez' | 'panama' | ...)[],
ecaKm?: number,
ecaFraction?: number,
co2eTonnes?: number,
}
}
Full options
seaRoute(origin, destination, {
units: 'nauticalmiles',
restrictions: ['suez', 'babelmandeb'],
via: ['panama'],
allowArctic: false,
vesselDraftMeters: 15,
speedKnots: 22,
appendOriginDestination: false,
returnPassages: true,
maxSnapDistanceKm: 50,
network: customMarnet,
antimeridian: 'split',
emissions: true,
vesselClass: 'panamax',
co2eFactorKgPerKm: 225,
glecInflation: 0.15,
});
Inputs can be [lon, lat] arrays, GeoJSON Feature<Point>, bare Point objects,
or a UN/LOCODE string (e.g. 'CNSHA') once searoute-ts/ports is imported.
Antimeridian (dateline) handling
Routes that cross the Β±180Β° meridian (e.g. Yokohama β LA) come back wrapped to
[-180, 180] by default, which many map renderers draw as a straight streak
across the whole map. Pass antimeridian to get map-ready geometry:
seaRoute(yokohama, la, { antimeridian: 'unwrap' });
seaRoute(yokohama, la, { antimeridian: 'split' });
'unwrap' shifts longitudes by multiples of 360Β° so the line never jumps the
dateline (ideal for MapLibre/Leaflet/Deck.gl). 'split' cuts the route into a
MultiLineString at Β±180Β°, keeping every coordinate in range. Both apply to
seaRoute and seaRouteMulti; properties.length is unchanged either way.
Forcing routes through a passage (via)
restrictions blocks a passage; via requires one β the inverse. Use
it to compare explicit routings, e.g. "via Suez" against "via Cape of Good Hope",
or to force a Pacific + Panama routing between Asia and Europe:
seaRoute('CNSHA', 'NLRTM', { via: ['suez'] });
seaRoute('CNSHA', 'NLRTM', { via: ['panama'] });
via accepts the same passage names as restrictions and visits multiple
passages in the order given. It routes origin β passage β destination through
each passage's location using the multi-leg machinery, so it composes with the
other options. A passage named in via is never blocked out from under the
requirement (via: ['northeast'] reaches the Northeast Passage without also
needing allowArctic). Naming the same passage in both via and restrictions
is a contradiction and throws NoRouteError.
Emissions & ECA/SECA reporting
Opt in with emissions: true for two rough estimates on properties:
import 'searoute-ts/eca';
import { seaRoute } from 'searoute-ts';
const r = seaRoute('CNSHA', 'NLRTM', {
emissions: true,
vesselClass: 'panamax',
});
r.properties.ecaKm;
r.properties.ecaFraction;
r.properties.co2eTonnes;
ecaKm β how much of the route lies inside ECA/SECA emission-control
areas (Baltic, North Sea, Mediterranean, North American and US Caribbean),
which drives fuel-type/cost. The zones ship behind the searoute-ts/eca
subpath export (to keep the core lean); importing it registers them. They are
bounding-box approximations of the IMO MARPOL Annex VI areas β good for
estimates, not compliance. Swap in higher-fidelity polygons with
registerEcaZones.
co2eTonnes β a deliberately simple distance Γ vessel-class factor
estimate, not a certified figure. Factors are derived transparently from a
representative fuel burn and the IMO HFO COβ conversion (see VESSEL_CLASSES);
override with co2eFactorKgPerKm. GLEC recommends inflating shortest-path
distance by ~15 % for real-world deviations β pass glecInflation: 0.15.
Restrictable passages
The first twelve are natively labelled in the Eurostat marnet (exact match
on the feature's pass attribute). The remaining four are detected via
bounding boxes.
| Name | Type | Notes |
|---|
suez | native | Suez Canal |
panama | native | Panama Canal |
gibraltar | native | Strait of Gibraltar |
babelmandeb | native | Bab-el-Mandeb (babalmandab alias) |
malacca | native | Malacca Strait |
dover | native | Dover Strait |
kiel | native | Kiel Canal |
corinth | native | Corinth Canal |
bering | native | Bering Strait |
magellan | native | Strait of Magellan |
northwest | native | Northwest Passage (blocked by default) |
northeast | native | Northeast Passage (blocked by default) |
bosporus | bbox | Bosphorus |
ormuz | bbox | Strait of Hormuz |
sunda | bbox | Sunda Strait |
cape_horn | bbox | Cape Horn region |
The Northwest and Northeast Passages are mathematically the shortest path for
many Asia β Europe routes but are ice-blocked most of the year, so they are
blocked by default. Opt in with allowArctic: true.
Validated against industry distances
12 real-world lanes within Β±10% of published Searoutes / Sea-Distances figures.
| Lane | searoute-ts | Industry ref. |
|---|
| Shanghai β Rotterdam (Suez) | 19 753 km | ~19 300 km |
| Singapore β Rotterdam (Suez) | 15 630 km | ~15 500 km |
| Mumbai β Rotterdam (Suez) | 11 918 km | ~11 800 km |
| NY β Rotterdam | 6 227 km | ~6 200 km |
| NY β LA (Panama) | 9 154 km | ~9 100 km |
| Yokohama β LA | 9 145 km | ~8 800 km |
| Singapore β LA (trans-Pacific) | 14 364 km | ~14 300 km |
| Caldera (CL) β BahΓa Blanca (AR) | 4 810 km | ~5 180 km |
All checks pass in the test suite.
Errors
SnapFailedError β input cannot be projected onto the network within
maxSnapDistanceKm. Carries .side: 'origin' | 'destination' and
.distanceKm: number.
NoRouteError β no path exists between the snapped origin and destination
(e.g. all viable canals blocked).
API reference
import {
seaRoute,
seaRouteMulti,
seaRouteAlternatives,
loadNetwork,
CANAL_MAX_DRAFT_M,
DEFAULT_MARNET,
PASSAGE_BBOXES,
clearFinderCache,
SnapFailedError,
NoRouteError,
UnknownPortError,
registerPortResolver,
type Passage,
type Antimeridian,
type SeaRouteOptions,
type SeaRouteFeature,
type SeaRouteMultiFeature,
type SeaRouteProperties,
type LoadNetworkOptions,
type MarnetNetwork,
type MarnetProperties,
} from 'searoute-ts';
import {
lookupPort,
resolvePort,
PORTS,
PORT_COUNT,
type Port,
type PortRecord,
} from 'searoute-ts/ports';
Port codes (UN/LOCODE)
Origins and destinations may be given as UN/LOCODE strings (e.g. 'CNSHA')
instead of coordinates. The port dataset ships behind the searoute-ts/ports
subpath export, so consumers only pay for it if they use it β importing the
subpath (for any of its exports, or purely for its side effect) registers a
resolver into the core so seaRoute('CNSHA', 'NLRTM') works.
- ~1 600 seaports, keyed by UN/LOCODE (primary codes and aliases).
- Source: marchah/sea-ports (MIT),
itself derived from UN/LOCODE. Regenerate with
scripts/build-ports.cjs.
- Coordinates are approximate (port-city granularity) β the routing engine
snaps them onto the network anyway, so this is fine for distance/visualisation.
- Unknown or malformed codes throw
UnknownPortError.
Use from an AI agent (MCP)
A companion Model Context Protocol server,
@searoute-ts/mcp (source),
lets AI agents (Claude Desktop, the claude CLI, etc.) compute real sea routes
instead of guessing β asking "how far is Shanghai to Rotterdam by sea, avoiding
Suez?" calls the library directly. It exposes two tools, sea_route and
sea_route_alternatives, and accepts port codes ('CNSHA') or coordinates.
claude mcp add searoute -- npx -y @searoute-ts/mcp
Or add it to any MCP client config:
{
"mcpServers": {
"searoute": {
"command": "npx",
"args": ["-y", "@searoute-ts/mcp"]
}
}
}
See the server's README
for the full tool reference. For the rail leg, add
@railroute-ts/mcp alongside it
(claude mcp add railroute -- npx -y @railroute-ts/mcp).
Multimodal: add the rail leg (railroute-ts)
Sea distance is rarely the whole shipment. The sibling library
railroute-ts routes over the
OpenStreetMap rail network (Europe bundled, same API shape, same GeoJSON
output), so a port-to-inland quote or a GLEC/CountEmissions-style report is one
extra call:
import 'searoute-ts/ports';
import { seaRoute } from 'searoute-ts';
import { railRoute } from 'railroute-ts';
import { EUROPE_NETWORK } from 'railroute-ts/networks/europe';
const sea = seaRoute('CNSHA', 'NLRTM', { units: 'kilometers', emissions: true, vesselClass: 'panamax' });
const rail = railRoute([4.47, 51.92], [8.92, 44.41], { network: EUROPE_NETWORK, speedKmh: 60 });
sea.properties.length;
sea.properties.co2eTonnes;
rail.properties.length;
rail.properties.gaugeChanges;
npm install railroute-ts β docs & interactive demo.
Both libraries also ship MCP servers, so an AI agent can chain sea_route β
rail_route for door-to-door distance (see below).
How it works
A two-page deep-dive (graph data, snapping, Dijkstra, restrictions,
antimeridian fix, draft logic, alternatives) is in DOCS.md.
FAQ
Is this for navigation? No. The routes are network paths suitable for
visualisation and rough distance/duration estimates, not for piloting ships.
Does it support weather routing? No. For weather-aware routing see
VISIR-2.
Why are my AsiaβEurope routes going through Bering Strait? They aren't,
by default β the Northwest and Northeast Passages are blocked. Pass
allowArctic: true to enable them.
Can I use my own network? Yes β seaRoute(origin, destination, { network }).
Useful for inland waterways or AIS-derived custom graphs. For higher-resolution
Eurostat data (5/10/20/50 km), see
Higher-resolution networks β 20 km and
50 km ship as subpath exports.
Does it handle the Red Sea / Suez crisis? Yes β pass
restrictions: ['suez', 'babelmandeb'] to force Cape of Good Hope routing.
Is the great-circle distance correct across the antimeridian? Yes β the
marnet has been normalised so the Pacific is a connected graph, and all
distances use haversine internally.
What's the bundle size? What you import at runtime is small: the core plus
the bundled 100 km marnet (~1.1 MB JSON, shipped once as a shared
dist/data/marnet.cjs asset both builds load, rather than inlined into each).
Tree-shakeable, so the optional searoute-ts/marnet-20km / marnet-50km
networks only load if you import them. They do add to the npm tarball, though β
including them the package is ~1.1 MB packed / ~7 MB unpacked (each variant is a
single shared asset, not duplicated per build). If you need the finer networks
without the install cost, generate and host them and use loadNetwork instead.
Credits
License
MIT Β© Mayur Rawte