A Complete Guide to Web Maps
Jul 8, 2025 · Updated 4 days ago
Web maps are hard. It's easy to just use the out-of-the-box lib like OpenStreetMap. But if you want fully control the style of every bit, it's a complex job.
This guide walks you through everything you need to build a fully custom, real-world map on the web — picking a data source, choosing a rendering engine, styling the map, and getting it on screen.
Check out the demo to see what the end result looks like.

How Web Maps Work
Before writing any code, it helps to understand what a web map is actually made of. There are three core pieces:
- Data sources provide the raw geographic information — place names, water bodies, contour lines, roads, and so on. You can tap into a public provider or host your own. Each rendering engine has its own conventions for how data is served. Common providers include:
- Google Maps
- MapTiler — a commercial map provider
- OpenStreetMap — community-maintained, like Wikipedia for maps
- Stadia Maps — a commercial map provider
- Various region-specific providers (Amap/Gaode in China, etc.)
- Styles define how the map looks — colors, fonts, which features are visible at which zoom levels. Different engines use different style specs.
- Rendering engines take the data and the styles and draw them on screen. The main options are:
- Mapbox GL JS — partially open-source; using its official data source requires a paid plan
- MapLibre GL JS — a fully open-source fork of Mapbox, with its own open style specification
- Leaflet — open-source, with JS and React SDKs
- OpenLayers — open-source
Because each engine has its own spec, data providers often ship SDKs that handle format conversion between them.
A note on compliance: serving maps in mainland China requires following national surveying regulations, including the use of the GCJ-02 encrypted coordinate system and obtaining a map review number. And regardless of where you operate, you're generally expected to visibly credit your data source.
Rendering Under the Hood
Map data is typically split into discrete vector tiles. Each tile covers a specific geographic region at a specific zoom level.
Taking MapLibre as an example, a map can be understood from two perspectives:
- Tiles — viewed from above, the map is a grid of tiles, much like tiles in a 2D game engine. Each tile is identified by a triplet
(z, x, y). - Layers — viewed in cross-section, the map is a stack of layers: water on one layer, roads on another, labels on yet another. Zoom levels range from 0 (the whole globe) up to 20+ (street-level detail). Each zoom increment quadruples the number of tiles (doubling both axes).
Tiles, fonts, and other map assets are stored in the PBF (Protocol Buffer Binary) format. PBF encodes vector data — points, lines, and polygons — rather than pre-rendered raster images. This means the rendering engine (e.g., MapLibre GL JS) can leverage WebGL for hardware-accelerated rendering on the client.
PBF is a binary format, so it's far more compact than text-based alternatives like XML or JSON. An OpenStreetMap PBF file is typically half the size of its gzip-compressed XML equivalent, and about 30% smaller than a bzip2-compressed one.
It's also the core format of the Mapbox Vector Tile specification, which is widely adopted (OpenStreetMap, Mapbox, Tilezen, etc.), ensuring broad compatibility and interoperability.
To make this concrete, let's build a map styled after Red Dead Redemption 2.
Step 1 · Pick a Data Source and Rendering Engine
Since we're mapping the real world, we need real-world data. We'll use MapTiler here.
- Sign up for a free MapTiler account.
- Generate an API key — you'll append it as a query parameter to every request.
Both MapTiler and Stadia Maps allow free usage during local development. If cost is a concern, you could set up a proxy that mimics localhost requests for production use.
In Next.js, for example, you can create an API route to handle this.
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } },
) {
try {
const path = params.path.join("/");
const searchParams = request.nextUrl.searchParams;
// Construct the MapTiler URL
const maptilerUrl = new URL(`https://api.maptiler.com/${path}`);
// Copy all search params to the MapTiler URL
searchParams.forEach((value, key) => {
maptilerUrl.searchParams.append(key, value);
});
// Add the API key
maptilerUrl.searchParams.append("key", MAPTILER_API_KEY || "");
// Forward relevant headers from the original request
const headers = new Headers();
// Set headers to make it appear as if the request is coming from localhost
headers.set("Host", "localhost:3000");
headers.set("Origin", "http://localhost:3000");
headers.set("Referer", "http://localhost:3000");
headers.set("Accept", "application/json");
headers.set("User-Agent", "Mozilla/5.0");
// Fetch from MapTiler with forwarded headers
const response = await fetch(maptilerUrl.toString(), {
headers,
cache: "no-store",
});
const data = await response.arrayBuffer();
const newHeaders = new Headers();
// Forward MapTiler response headers
response.headers.forEach((value, key) => {
// Skip content-encoding and transfer-encoding headers
if (
key.toLowerCase() !== "content-encoding" &&
key.toLowerCase() !== "transfer-encoding" &&
key.toLowerCase() !== "content-length"
) {
newHeaders.set(key, value);
}
});
// Add CORS headers
newHeaders.set("Access-Control-Allow-Origin", "*");
newHeaders.set("Access-Control-Allow-Methods", "GET, OPTIONS");
newHeaders.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
// Set content length for the new response
newHeaders.set("Content-Length", data.byteLength.toString());
// If this is a tiles.json response, we need to rewrite the URLs
if (path.endsWith("tiles.json")) {
const text = new TextDecoder().decode(data);
const json = JSON.parse(text);
// Get the current host from the request
const host =
process.env.NODE_ENV === "production"
? process.env.NEXT_PUBLIC_HOST ||
request.headers.get("host")
: request.headers.get("host") || request.nextUrl.host;
const protocol =
process.env.NODE_ENV === "production" ? "https" : "http";
// Rewrite tile URLs to use our proxy
if (json.tiles) {
json.tiles = json.tiles.map((url: string) => {
// Replace the MapTiler domain with our host
return url.replace(
"https://api.maptiler.com",
`${protocol}://${host}/api/maptiler`,
);
});
}
// Rewrite glyphs URL if present
if (json.glyphs) {
json.glyphs = json.glyphs.replace(
"https://api.maptiler.com",
`${protocol}://${host}/api/maptiler`,
);
}
// Convert back to buffer
const newData = new TextEncoder().encode(JSON.stringify(json));
newHeaders.set("Content-Length", newData.byteLength.toString());
return new NextResponse(newData, {
headers: newHeaders,
});
}
return new NextResponse(data, {
headers: newHeaders,
});
} catch (error) {
return NextResponse.json(
{ error: "Failed to proxy MapTiler request" },
{
status: 500,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
);
}
}
Note: this workaround is for learning purposes only — don't use it in production.
Step 2 · Design the Map Style
MapTiler's rendering relies on a JSON style file. This file is where the magic happens.
MapTiler provides an official visual editor called Maputnik. You can open one of the built-in styles and tweak it, or start from scratch. Starting from an existing style is usually faster.
You can also edit the JSON directly. Pairing this with an AI coding assistant works surprisingly well.
Think carefully about your design: color palette, typography, which elements appear at which zoom levels — every detail matters. Layer visibility can be tied to zoom level, so features can appear and disappear as the user zooms in and out.
Here's the design language we're going for — the Red Dead Redemption 2 map aesthetic:

We need to identify the fonts by eye. I'm no typographer, but here's my best guess:
- Water bodies / landmarks: Ephesis
- State (province) names: Merriweather
- City names: Outfit
- Water labels: Metal
Taking water body labels as an example:
- Set Rotation Alignment to
Viewport(in RDR2, landscape labels always face the camera — they never warp or rotate) - Set the Font to Ephesis
- Match the Halo color to the text color, then use Halo Width to control the apparent font weight
Next, let's add contour lines.
The default style doesn't include contour data, but the RDR2 map clearly has topographic lines. We need to add an additional data source for the contour layer. MapTiler provides one out of the box:
/api/maptiler/tiles/contours-v2/tiles.json?key=Qnr580YQzLS9WFZBXZYE
You can also host your own. First, add a new source called contours in the Sources panel:
Then add a new contour line layer using the Line type. Point it at the source you just created and configure the color, width, and other properties.
Once you're happy with the design, export the JSON file.
Many style properties support expressions — instead of fixed values, they can be computed dynamically based on conditions. Layers themselves also support conditional rendering. For instance, the contour lines above are configured to only appear between zoom levels 10 and 24; zoom out further and they disappear.
Step 3 · Use Custom Fonts
If you're fine with the default fonts, skip this step.
Unlike CSS, you can't just point to a .ttf file. Map fonts need to be converted to PBF glyph files, just like every other map asset.
MapLibre maintains an open-source tool called Font Maker that handles the conversion. Upload all the fonts you need.
After conversion, host the generated PBF files somewhere publicly accessible so the client can fetch them.
The engine resolves fonts through a URL template like this:
https://www.example.com/font-server/{fontstack}/{range}.pbf
{fontstack} is the font name, so your server's file structure should look something like this:

Then add the glyph URL to your style JSON:
"glyphs": "https://chizu.ygeeker.com/font-server/{fontstack}/{range}.pbf",
You can also set this in Maputnik's settings — it'll write it into the JSON for you.
Once the glyph source is configured, you can reference font names in any text/symbol layer. The name corresponds to the font's folder name on the server.
Step 4 · Render the Map in Your App
In React, it's as simple as loading the style JSON. From there, you can use the MapLibre API for more advanced features. Here's a basic example:
useEffect(() => {
if (!mapContainer.current || !mapLocation) return;
let map: maplibregl.Map;
fetch("/redemption.json")
.then((res) => res.json())
.then((style) => {
map = new maplibregl.Map({
container: mapContainer.current!,
style,
center: mapLocation.center,
zoom: mapLocation.zoom,
});
mapRef.current = map;
// Save location when map moves
map.on("moveend", () => {
const newLocation = {
center: map.getCenter().toArray() as [number, number],
zoom: map.getZoom(),
};
localStorage.setItem(
"mapLocation",
JSON.stringify(newLocation),
);
});
});
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [mapLocation]);