Using Township Canada API with Leaflet
Add legal land description search, boundary polygons, and location markers to your Leaflet map using the Township Canada API. Lightweight and open-source.
Build a web map that searches Canadian legal land descriptions, places markers, and draws parcel boundaries — all using Leaflet and the Township Canada API.
Leaflet is a lightweight, open-source mapping library with no vendor dependencies. It does not have native vector tile support, so this guide focuses on the Search API and Autocomplete API for the core workflow. Vector tile support is covered separately at the end.
What you'll build
By the end of this guide, you'll have a web page that:
- Displays a Leaflet map centred on Calgary
- Searches legal land descriptions (e.g., NW-25-24-1-W5) and places a marker at the result
- Draws the parcel boundary polygon from the API response
- Shows a popup with legal land description details on the marker
- Includes a live autocomplete search box with debounced input
Prerequisites
- A Township Canada API key — subscribe to the Search API from the Developer Portal, then create an API key from your account settings
- Basic knowledge of HTML and JavaScript
- No build tools required — everything loads from CDN
Step 1: Set up the map
Create an index.html file and load Leaflet from CDN:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + Leaflet</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body {
margin: 0;
padding: 0;
}
#map {
height: 100vh;
width: 100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
const TC_API_KEY = "YOUR_API_KEY";
const map = L.map("map").setView([51.05, -114.07], 10); // Calgary, AB
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
</script>
</body>
</html>
Open the file in a browser. You should see a map centred on Calgary using OpenStreetMap tiles.
You can swap the tile layer for any provider. CartoDB Positron is a good choice for a cleaner background:
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
subdomains: "abcd"
}).addTo(map);
Step 2: Search and place markers
Use the Search API to convert a legal land description to coordinates, then place a marker with a popup.
Search API endpoint
GET https://developer.townshipcanada.com/search/legal-location?location={query}
X-API-Key: YOUR_API_KEY
The response is a GeoJSON FeatureCollection with two features: a polygon (the parcel boundary) and a centroid point. See the API Integration Guide for the full response schema.
Placing a marker
async function searchLocation(query) {
const response = await fetch(
`https://developer.townshipcanada.com/search/legal-location?location=${encodeURIComponent(query)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
if (!data.features || data.features.length === 0) {
console.error("No results found for:", query);
return;
}
// The centroid feature has shape: "centroid" in its properties
const centroid = data.features.find((f) => f.properties.shape === "centroid");
if (!centroid) return;
const [lng, lat] = centroid.geometry.coordinates;
const props = centroid.properties;
// Leaflet uses [lat, lng] — note the order
const marker = L.marker([lat, lng]).addTo(map);
marker
.bindPopup(
`<strong>${props.legal_location}</strong><br>` + `${lat.toFixed(6)}, ${lng.toFixed(6)}`
)
.openPopup();
map.setView([lat, lng], 14);
}
// Example: search for a quarter section in Alberta
searchLocation("NW-25-24-1-W5");
Note that Leaflet expects coordinates as [lat, lng], while GeoJSON uses [lng, lat]. Swap the order when passing coordinates to any Leaflet method.
Step 3: Show boundary polygons
The Search API response includes the polygon feature alongside the centroid. Pass it directly to L.geoJSON() to draw the parcel boundary.
async function searchLocation(query) {
const response = await fetch(
`https://developer.townshipcanada.com/search/legal-location?location=${encodeURIComponent(query)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
if (!data.features || data.features.length === 0) return;
const centroid = data.features.find((f) => f.properties.shape === "centroid");
const polygon = data.features.find((f) => f.geometry.type === "MultiPolygon");
// Remove previous results
if (window.currentMarker) window.currentMarker.remove();
if (window.currentPolygon) window.currentPolygon.remove();
if (centroid) {
const [lng, lat] = centroid.geometry.coordinates;
const props = centroid.properties;
window.currentMarker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`<strong>${props.legal_location}</strong><br>` + `${lat.toFixed(6)}, ${lng.toFixed(6)}`
)
.openPopup();
map.setView([lat, lng], 14);
}
if (polygon) {
window.currentPolygon = L.geoJSON(polygon, {
style: {
color: "#2d5a47",
weight: 2,
fillColor: "#2d5a47",
fillOpacity: 0.12
}
}).addTo(map);
// Fit the map to the polygon bounds
map.fitBounds(window.currentPolygon.getBounds(), { padding: [40, 40] });
}
}
L.geoJSON() accepts a single GeoJSON Feature or FeatureCollection. The style option sets the stroke colour, fill colour, and opacity. Calling fitBounds() on the polygon layer zooms to show the full parcel without manual coordinate math.
Step 4: Add autocomplete search
Build a search input with live suggestions from the Autocomplete API.
Autocomplete API endpoint
GET https://developer.townshipcanada.com/autocomplete/legal-location?location={query}&limit=3
X-API-Key: YOUR_API_KEY
HTML
Add this above the map <div>:
<div
id="search-container"
style="
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
width: 320px;
"
>
<input
id="search-input"
type="text"
placeholder="e.g. NW-25-24-1-W5 or 14-27-048-05W5"
style="
width: 100%;
padding: 10px 14px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
box-sizing: border-box;
"
/>
<ul
id="suggestions"
style="
list-style: none;
margin: 4px 0 0;
padding: 0;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
display: none;
"
></ul>
</div>
JavaScript
const searchInput = document.getElementById("search-input");
const suggestionsEl = document.getElementById("suggestions");
let debounceTimer;
searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
if (query.length < 2) {
suggestionsEl.style.display = "none";
return;
}
// Wait 300ms after the user stops typing before fetching
debounceTimer = setTimeout(() => fetchSuggestions(query), 300);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
suggestionsEl.style.display = "none";
searchLocation(searchInput.value.trim());
}
});
async function fetchSuggestions(query) {
const response = await fetch(
`https://developer.townshipcanada.com/autocomplete/legal-location` +
`?location=${encodeURIComponent(query)}&limit=3`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
suggestionsEl.innerHTML = "";
if (!data.features || data.features.length === 0) {
suggestionsEl.style.display = "none";
return;
}
data.features.forEach((feature) => {
const li = document.createElement("li");
li.textContent = `${feature.properties.legal_location} (${feature.properties.province})`;
li.style.cssText = `
padding: 10px 14px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #eee;
`;
li.addEventListener("mouseenter", () => (li.style.backgroundColor = "#f5f5f5"));
li.addEventListener("mouseleave", () => (li.style.backgroundColor = "#fff"));
li.addEventListener("click", () => {
searchInput.value = feature.properties.legal_location;
suggestionsEl.style.display = "none";
searchLocation(feature.properties.legal_location);
});
suggestionsEl.appendChild(li);
});
suggestionsEl.style.display = "block";
}
// Close the dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!document.getElementById("search-container").contains(e.target)) {
suggestionsEl.style.display = "none";
}
});
The debounce prevents a request on every keystroke. 300ms is a good default — it feels responsive without hammering the API.
Step 5: Vector tiles (advanced)
Leaflet does not support MVT vector tiles out of the box. If you need to display Township Canada survey grid overlays alongside your search results, there are two options:
protomaps-leaflet is a lightweight MVT renderer that works as a Leaflet layer. It handles the Township Canada tile URL format and requires no server-side changes.
mapbox-gl-leaflet bridges Mapbox GL JS into a Leaflet map. It gives you full GL rendering, but adds Mapbox GL JS as a dependency and is heavier than the alternatives.
For most use cases, the Search API approach in this guide — marker + polygon per search — covers the common workflow without the complexity of a vector tile pipeline. If your application needs to display full survey grids (DLS townships, sections, and LSDs) across a large area, the Mapbox GL JS guide is a better fit and gives you native MVT support with no plugins.
Full working example
Here's the complete HTML file combining all the steps above. Replace YOUR_API_KEY with your Township Canada API key.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Township Canada + Leaflet</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
#map {
height: 100vh;
width: 100%;
}
#search-container {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
width: 340px;
}
#search-input {
width: 100%;
padding: 10px 14px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
outline: none;
}
#search-input:focus {
border-color: #2d5a47;
box-shadow: 0 0 0 2px rgba(45, 90, 71, 0.2);
}
#suggestions {
list-style: none;
margin: 4px 0 0;
padding: 0;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
display: none;
overflow: hidden;
}
#suggestions li {
padding: 10px 14px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #eee;
color: #333;
}
#suggestions li:last-child {
border-bottom: none;
}
#suggestions li:hover {
background: #f5f5f5;
}
</style>
</head>
<body>
<div id="search-container">
<input
id="search-input"
type="text"
placeholder="e.g. NW-25-24-1-W5 or 14-27-048-05W5"
autocomplete="off"
/>
<ul id="suggestions"></ul>
</div>
<div id="map"></div>
<script>
// --- Configuration ---
const TC_API_KEY = "YOUR_API_KEY";
const TC_API = "https://developer.townshipcanada.com";
// --- Map ---
const map = L.map("map").setView([51.05, -114.07], 10);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
subdomains: "abcd",
maxZoom: 19
}).addTo(map);
// --- State ---
let currentMarker = null;
let currentPolygon = null;
// --- Search ---
async function searchLocation(query) {
if (!query) return;
try {
const response = await fetch(
`${TC_API}/search/legal-location?location=${encodeURIComponent(query)}`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
if (!response.ok) {
console.error("Search failed:", response.status);
return;
}
const data = await response.json();
if (!data.features || data.features.length === 0) return;
const centroid = data.features.find((f) => f.properties.shape === "centroid");
const polygon = data.features.find((f) => f.geometry.type === "MultiPolygon");
// Remove previous results
if (currentMarker) {
currentMarker.remove();
currentMarker = null;
}
if (currentPolygon) {
currentPolygon.remove();
currentPolygon = null;
}
if (centroid) {
const [lng, lat] = centroid.geometry.coordinates;
const props = centroid.properties;
currentMarker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`<strong>${props.legal_location}</strong><br>` +
`<span style="color:#666; font-size:12px;">${lat.toFixed(6)}, ${lng.toFixed(6)}</span>`
)
.openPopup();
}
if (polygon) {
currentPolygon = L.geoJSON(polygon, {
style: {
color: "#2d5a47",
weight: 2,
fillColor: "#2d5a47",
fillOpacity: 0.12
}
}).addTo(map);
map.fitBounds(currentPolygon.getBounds(), { padding: [60, 60] });
} else if (centroid) {
const [lng, lat] = centroid.geometry.coordinates;
map.setView([lat, lng], 14);
}
} catch (err) {
console.error("Search error:", err);
}
}
// --- Autocomplete ---
const searchInput = document.getElementById("search-input");
const suggestionsEl = document.getElementById("suggestions");
let debounceTimer;
searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
if (query.length < 2) {
suggestionsEl.style.display = "none";
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(query), 300);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
suggestionsEl.style.display = "none";
searchLocation(searchInput.value.trim());
}
});
async function fetchSuggestions(query) {
try {
const response = await fetch(
`${TC_API}/autocomplete/legal-location?location=${encodeURIComponent(query)}&limit=3`,
{ headers: { "X-API-Key": TC_API_KEY } }
);
const data = await response.json();
suggestionsEl.innerHTML = "";
if (!data.features || data.features.length === 0) {
suggestionsEl.style.display = "none";
return;
}
data.features.forEach((feature) => {
const li = document.createElement("li");
li.textContent = `${feature.properties.legal_location} (${feature.properties.province})`;
li.addEventListener("click", () => {
searchInput.value = feature.properties.legal_location;
suggestionsEl.style.display = "none";
searchLocation(feature.properties.legal_location);
});
suggestionsEl.appendChild(li);
});
suggestionsEl.style.display = "block";
} catch (err) {
suggestionsEl.style.display = "none";
}
}
document.addEventListener("click", (e) => {
if (!document.getElementById("search-container").contains(e.target)) {
suggestionsEl.style.display = "none";
}
});
</script>
</body>
</html>
Save the file, replace YOUR_API_KEY, and open it in a browser. Type a legal land description like NW-25-24-1-W5 or 14-27-048-05W5 into the search box. Autocomplete suggestions appear as you type, and selecting one places a marker and draws the parcel boundary on the map.
Next steps
- Use the Batch API to plot multiple parcels at once — collect the centroid coordinates and call
L.marker()in a loop - Add click handlers on the polygon layer using
layer.on('click', ...)to show parcel details on demand - Switch to the Mapbox GL JS guide if you need full survey grid overlays (DLS townships, sections, LSDs) — Mapbox has native vector tile support and no extra plugins required
Related guides
- API Integration Guide — API endpoints, authentication, and rate limits
- Mapbox GL JS Integration — Vector tile support with native MVT rendering
- Autocomplete API Guide — Autocomplete endpoint details and response schema
- What is a Legal Land Description? — DLS and NTS survey systems explained
Related Guides
Legal Land Description API Integration Guide
Integrate legal land description APIs into your applications. Convert LLDs to coordinates, add autocomplete search, process batch records, and display DLS/NTS grid maps. REST API with JSON responses.
Managing API Keys for Development, Staging, and Production
Create and manage multiple Township Canada API keys for different environments. Naming conventions, key rotation, environment variables, and CI/CD setup.
Building Autocomplete Search with the Township Canada API
Build a search-as-you-type component for legal land descriptions using the Township Canada Autocomplete API. Includes debouncing, proximity biasing, and examples in vanilla JS and React.