How to Add Custom Marker Icons to Leaflet, MapLibre GL, and OpenLayers Maps
- API or service
- Marker Icon API + JavaScript map libraries
- Task
- Locations + marker style → precisely anchored map markers
- Examples
- Difficulty
- Beginner
- Time
- 15 min
You have a list of locations and want to show them on an interactive map with custom marker icons that point exactly at each location. The Geoapify Marker Icon API generates the marker PNG from URL parameters and returns its display size and anchor point in the Icon-* response headers. Leaflet, MapLibre GL, or OpenLayers then draws the image and puts the anchor on the location.
Input: coordinates of the locations and one marker style, for example a pink Font Awesome pin with a landmark icon.
Result: markers whose pin tip touches the exact coordinates, at any marker type, size, shadow style, and screen resolution, with popups that open above the pin.
Task flow: marker style → one Marker Icon API request → PNG image + Icon-Width, Icon-Height, Icon-Anchor-X, Icon-Anchor-Y headers → map-library icon with that size and anchor → one marker per location.
The Marker Icon API only renders the image and measures it. The map library positions, scales, and displays the markers in the browser. The examples show three Washington, DC landmarks:
![]()
For a quick start with fixed sizes, see How to Add Markers to a Map. This guide explains why fixed values break when the marker style changes and how to avoid that.
Plan the marker: size, shadow, and anchor
A marker image has three properties that the map library needs: its display size, its resolution, and its anchor. The Marker Icon API controls all three with URL parameters, but the resulting values are not always the ones you request.
Display size is larger than size
size is the height of the marker shape. The default shadow is drawn below the shape and makes the image taller:
| Request | Displayed image | Pin tip (anchor) |
|---|---|---|
type=awesome&size=42 |
32 × 47 CSS px | [16, 42.7] |
type=awesome&size=42&noShadow |
32 × 42 CSS px | [16, 42] |
type=awesome&size=64 |
49 × 71 CSS px | [24.5, 64.4] |
type=material&size=42&shadowType=drop |
46 × 62 CSS px | [23, 49.3] |
The drop and glow shadows blur around the whole shape, so they also make the image wider and move the horizontal anchor. The layout tables list all marker types and shadow styles.
The anchor is inside the shadow
Leaflet, MapLibre GL, and OpenLayers anchor images at their center by default, and the usual fix for pins is a bottom-center anchor. Both are wrong for a pin with a shadow. With a bottom-center anchor, the 32 × 47 px pin tip is about 4.5 px above the location:
![]()
The pin tip is the anchor. The API returns its position as Icon-Anchor-X and Icon-Anchor-Y, measured in CSS pixels from the top-left corner of the displayed image.
Resolution: scaleFactor
Use scaleFactor=2 so markers stay sharp on high-density screens. The PNG then has twice the pixels: 64 × 94 px for a 32 × 47 px marker. Display it at the Icon-Width × Icon-Height size, which is already divided by scaleFactor.
The anchor is measured on the rounded PNG, so it differs slightly between resolutions: 42.74 at scaleFactor=1 and 42.49 at scaleFactor=2 for the same pin. Read the headers for the exact URL that you display.
Decision: read the layout or hardcode it
| Requirement | Approach |
|---|---|
| Marker style can change, or users choose it | Read the Icon-* headers at runtime, as in this guide |
| Fixed style, generated once and self-hosted | Read the headers once with a HEAD request and store the values with the image |
| Simplest possible math, no shadow needed | Use noShadow: the image is size px high and the anchor is [width / 2, size] |
This guide uses one marker style for all locations:
https://api.geoapify.com/v2/icon/?type=awesome&icon=landmark&color=%23ff005c&size=42&contentSize=18&scaleFactor=2&apiKey=YOUR_API_KEY
contentSize=18 is set explicitly, because the default content size does not grow with size. The type=awesome pin and the landmark icon come from Font Awesome, so the map needs Font Awesome attribution.
Request the marker and read its layout
Every Marker Icon API response carries the marker layout in its headers. Send a HEAD request to see the layout without downloading the image:
curl -I "https://api.geoapify.com/v2/icon/?type=awesome&icon=landmark&color=%23ff005c&size=42&contentSize=18&scaleFactor=2&apiKey=YOUR_API_KEY"
Response headers (reduced):
HTTP/2 200
content-type: image/png
cache-control: public, max-age=3600
icon-width: 32
icon-height: 47
icon-anchor-x: 16
icon-anchor-y: 42.49497841631692
icon-marker-style: type:awesome;size:42;icontype:awesome;icon:landmark;color:#ff005c;contentsize:18
access-control-expose-headers: Icon-Width, Icon-Height, Icon-Anchor-X, Icon-Anchor-Y, Icon-Marker-Style
| Header | Value | Meaning |
|---|---|---|
Icon-Width, Icon-Height |
32, 47 | Display size in CSS px, including the shadow. The PNG is 64 × 94 px, because scaleFactor=2. |
Icon-Anchor-X, Icon-Anchor-Y |
16, 42.49 | Pin tip, in CSS px from the top-left corner. It is 4.5 px above the image bottom. |
Icon-Marker-Style |
type:awesome;… |
The same marker for a Static Maps API marker parameter, after a lonlat: prefix and with # encoded as %23. |
access-control-expose-headers makes the Icon-* headers readable by browser scripts on any origin.
One request per marker style
A HEAD request is a full API request. In the browser, request the image once with fetch() and read the headers from the same response. The helper below returns the layout and an object URL of the downloaded PNG. All implementation pages use it:
const markerUrl = "https://api.geoapify.com/v2/icon/?type=awesome&icon=landmark&color=%23ff005c&size=42&contentSize=18&scaleFactor=2&apiKey=YOUR_API_KEY";
const locations = [
{ name: "Lincoln Memorial", lat: 38.8893, lon: -77.0502 },
{ name: "Washington Monument", lat: 38.8895, lon: -77.0353 },
{ name: "United States Capitol", lat: 38.8899, lon: -77.0091 }
];
// Requests the marker once and reads its display size and anchor
// (CSS pixels) from the Icon-* response headers.
async function loadMarker(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Marker request failed with HTTP ${response.status}`);
}
const header = (name) => Number(response.headers.get(name));
const marker = {
width: header("Icon-Width"),
height: header("Icon-Height"),
anchorX: header("Icon-Anchor-X"),
anchorY: header("Icon-Anchor-Y"),
imageUrl: URL.createObjectURL(await response.blob())
};
if (!(marker.width > 0 && marker.height > 0)) {
throw new Error("The marker response has no Icon-* layout headers");
}
return marker;
}
Result for this URL:
{ "width": 32, "height": 47, "anchorX": 16, "anchorY": 42.49497841631692, "imageUrl": "blob:…" }
Call loadMarker() once per marker style, not once per location. For several styles, call it for each style in parallel with Promise.all() and keep the results in a map keyed by style.
Errors are returned as JSON with an HTTP status code, for example 401 with Invalid apiKey or 400 with size should be a positive number. loadMarker() turns them into exceptions, so the map can show a fallback or an error message.
Add marker icons with Leaflet
Create one L.icon from the loaded marker and pass it to every L.marker. Leaflet puts the iconAnchor point on the location. Coordinates use [latitude, longitude] order.
const marker = await loadMarker(markerUrl);
// One icon object for every marker with this style.
const icon = L.icon({
iconUrl: marker.imageUrl,
iconSize: [marker.width, marker.height],
iconAnchor: [marker.anchorX, marker.anchorY],
// Relative to iconAnchor: open popups above the top of the pin.
popupAnchor: [marker.width / 2 - marker.anchorX, -marker.anchorY]
});
for (const location of locations) {
L.marker([location.lat, location.lon], { icon, alt: location.name })
.bindPopup(location.name)
.addTo(map);
}
- Size:
iconSizedisplays the 64 × 94 px PNG at 32 × 47 CSS px. - Anchor:
iconAnchor: [16, 42.49]puts the pin tip on the location. Leaflet accepts fractional values. - Popup:
popupAnchoris measured fromiconAnchor.[0, -42.49]opens the popup at the top edge of the pin, not over it. - Accessibility:
altgives each marker image an accessible name.
The code uses top-level await, so run it in a JavaScript module or inside an async function. The result screenshot was generated with this code.
Add marker icons with MapLibre GL
A MapLibre Marker is an HTML element above the map canvas. Its anchor option aligns one of nine named positions of the element with the location: center (default), the sides, or the corners. None of them is the pin tip above a shadow. Align the top-left corner with the location instead, and move the element up and left by the anchor. Coordinates use [longitude, latitude] order.
const marker = await loadMarker(markerUrl);
for (const location of locations) {
const element = document.createElement("img");
element.src = marker.imageUrl;
element.alt = location.name;
element.style.width = `${marker.width}px`;
element.style.height = `${marker.height}px`;
new maplibregl.Marker({
element,
// Put the image's top-left corner on the location,
// then shift it so the anchor point lands there instead.
anchor: "top-left",
offset: [-marker.anchorX, -marker.anchorY]
})
.setLngLat([location.lon, location.lat])
.setPopup(new maplibregl.Popup({ offset: [0, -marker.anchorY] }).setText(location.name))
.addTo(map);
}
- Size: set the width and height with CSS. The
widthandheightattributes of<img>accept only whole numbers, andIcon-Widthcan be fractional, for example42.5. - Anchor:
offset: [-16, -42.49]moves the image so the pin tip, not the top-left corner, is on the location. - Popup: the popup offset is measured from the location.
[0, -42.49]opens it at the top edge of the pin.
Every marker is a DOM element. For hundreds of points, use a symbol layer instead.
Render marker icons as a MapLibre GL symbol layer
A symbol layer draws the points in WebGL from a GeoJSON source and reuses one image for all of them. Use it for many points, filtering, or clustering. The anchor uses the same top-left technique as the Marker example.
map.on("load", async () => {
const marker = await loadMarker(markerUrl);
const image = await map.loadImage(marker.imageUrl);
// pixelRatio must equal the scaleFactor in the marker URL,
// so the image is drawn at its display size.
map.addImage("landmark-marker", image.data, { pixelRatio: 2 });
map.addSource("landmarks", {
type: "geojson",
data: {
type: "FeatureCollection",
features: locations.map((location) => ({
type: "Feature",
properties: { name: location.name },
geometry: { type: "Point", coordinates: [location.lon, location.lat] }
}))
}
});
map.addLayer({
id: "landmarks",
type: "symbol",
source: "landmarks",
layout: {
"icon-image": "landmark-marker",
"icon-anchor": "top-left",
"icon-offset": [-marker.anchorX, -marker.anchorY],
"icon-allow-overlap": true
}
});
});
- Resolution:
pixelRatio: 2draws the 64 × 94 px image at 32 × 47 CSS px. If you changescaleFactorin the URL, changepixelRatioto the same value. - Anchor:
icon-offsetis in CSS pixels and is applied aftericon-anchor, so[-16, -42.49]puts the pin tip on the point. - Style changes:
setStyle()removes added images and layers. Add them again after a style change; keep the loadedmarkerobject, so you don't request the image again. - Overlap:
icon-allow-overlap: trueshows every point. Remove it to let MapLibre hide overlapping markers.
Add marker icons with OpenLayers
In OpenLayers, markers are point features in a vector layer, and the marker image is an Icon style. The Icon anchor is a fraction of the image size by default, so divide the anchor by the display size. Convert [longitude, latitude] coordinates to the map projection with fromLonLat().
import Feature from "ol/Feature.js";
import Point from "ol/geom/Point.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import Icon from "ol/style/Icon.js";
import Style from "ol/style/Style.js";
import { fromLonLat } from "ol/proj.js";
const marker = await loadMarker(markerUrl);
const markerStyle = new Style({
image: new Icon({
src: marker.imageUrl,
width: marker.width,
height: marker.height,
// Fractions of the displayed size, so the anchor does not depend on scaleFactor.
anchor: [marker.anchorX / marker.width, marker.anchorY / marker.height]
})
});
const source = new VectorSource({
features: locations.map((location) => new Feature({
name: location.name,
geometry: new Point(fromLonLat([location.lon, location.lat]))
}))
});
map.addLayer(new VectorLayer({ source, style: markerStyle }));
- Size:
widthandheightdisplay the 64 × 94 px PNG at 32 × 47 CSS px. - Anchor:
[16 / 32, 42.49 / 47]=[0.5, 0.904]puts the pin tip on the location. Fractions stay correct when you changescaleFactoror the display size, so you never have to convert between PNG pixels and CSS pixels. - One style object: all features share
markerStyle, so OpenLayers loads the image once. - Popups: keep the name on the feature and read it in a click handler with
map.forEachFeatureAtPixel()to fill anOverlayor a side panel.
Prepare for production
Requests and cost. Each Marker Icon API request costs 1 credit, including HEAD requests. Request each marker style once and reuse the result for all locations. Responses are cacheable for one hour (Cache-Control: public, max-age=3600). For a fixed set of styles, generate the PNGs and their Icon-* values once, store them with your application, and serve them as static files. Storing and redistributing generated icons is allowed; see Marker Icon API pricing.
API key. A marker URL loaded in the browser exposes the API key. Restrict the key to your website's HTTP referrers or origins in Geoapify MyProjects, or self-host the generated markers so the browser never needs the key.
Missing headers. If loadMarker() throws because the Icon-* headers are missing, for example when a proxy strips them, fall back to measured values for the same URL or to noShadow markers with the anchor [width / 2, size].
Errors. Show the map even when a marker request fails. Catch the error from loadMarker(), log it without the URL (it contains the API key), and use a default library marker as a fallback.
Object URLs. URL.createObjectURL() keeps the image in memory while the page is open. That is the intended behavior for a style shared by all markers. If you create many temporary styles, call URL.revokeObjectURL() when a style is no longer displayed.
Attribution. On the Free plan, add Geoapify attribution. The type=awesome pin and Font Awesome icons also need Font Awesome attribution. See Marker Icon API attribution.
Accessibility. Give every marker an accessible name, such as the Leaflet alt option or the alt attribute of the MapLibre marker image. If markers differ by color, also make them differ by icon.
Custom marker icons with an AI coding agent
Copy this prompt into Codex, Claude Code, Cursor, or another coding agent to add Geoapify marker icons that are anchored exactly at their locations.
Add custom marker icons from the Geoapify Marker Icon API to the map in my project, with each pin tip placed exactly on its location.
Before you start
- Inspect the existing project first. Reuse its current framework, map library (Leaflet, MapLibre GL, or OpenLayers), components, styling, HTTP client, and conventions. Make the smallest necessary changes.
- Ask only what the project does not answer: which locations to show and where they come from, and which marker style to use (type, icon, color, size). If no style is specified, use type=material with a relevant icon, size=42, and scaleFactor=2.
- Read the Geoapify API key from an environment variable. Never hardcode, print, or log the key or full marker URLs.
Marker layout
- Build the marker URL from https://api.geoapify.com/v2/icon/ with URLSearchParams, so # in colors is encoded as %23. Set contentSize explicitly when size is not the default.
- Request each marker style once with fetch(). Read Icon-Width, Icon-Height, Icon-Anchor-X, and Icon-Anchor-Y from the response headers (CSS pixels, already divided by scaleFactor) and create an object URL from the PNG body. Do not hardcode the size or assume a bottom-center anchor: the shadow makes the image taller and the pin tip is above the image bottom.
- Throw an error when the request fails or the Icon-* headers are missing, and fall back to the library's default marker.
Map library
- Leaflet: one L.icon per style with iconSize [width, height], iconAnchor [anchorX, anchorY], and popupAnchor [width / 2 - anchorX, -anchorY]. Coordinates are [lat, lon].
- MapLibre GL Marker: an <img> sized with CSS, anchor "top-left", offset [-anchorX, -anchorY], popup offset [0, -anchorY]. Coordinates are [lon, lat].
- MapLibre GL symbol layer (for many points): addImage with pixelRatio equal to scaleFactor, icon-anchor "top-left", icon-offset [-anchorX, -anchorY]. Re-add images and layers after a style change.
- OpenLayers: one Icon style with width, height, and a fractional anchor [anchorX / width, anchorY / height]. Convert coordinates with fromLonLat([lon, lat]).
Finish
- Give each marker an accessible name. Add Font Awesome attribution if the marker uses type=awesome or iconType=awesome.
- Verify visually at a high zoom level that each pin tip touches its location, then report the files you changed.
Review the generated code against this guide. Check that each marker style is requested once, that size and anchor come from the Icon-* headers, that coordinates are in the order each library expects, and that the API key is not hardcoded.