Query SSURGO directly from the browser. No server required. Build live tables, connect SDA data to MapLibre GL maps, and ship reusable soil data modules.
The browser-native way to call REST APIs
fetch() is built into every modern browser — no libraries needed. SDA accepts cross-origin requests, so this works directly from any webpage.
const SDA = 'https://sdmdataaccess.sc.egov.usda.gov/Tabular/post.rest'; async function querySDA(sql) { const body = new URLSearchParams({ query: sql, format: 'JSON+COLUMNNAME' }); const resp = await fetch(SDA, { method: 'POST', body }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (!data.Table || data.Table.length < 2) return { headers: [], rows: [] }; const [headers, , ...rows] = data.Table; // Convert rows to objects with column names as keys return rows.map(row => Object.fromEntries(headers.map((h, i) => [h, row[i]]))); } // Usage const results = await querySDA( "SELECT TOP 5 musym, muname, muacres FROM mapunit ORDER BY muacres DESC" ); console.table(results);
Turn SDA JSON into a live dark table
async function renderTable(sql, containerId) { const el = document.getElementById(containerId); el.innerHTML = '<p style="color:#38bdf8">⟳ Loading…</p>'; try { const body = new URLSearchParams({ query: sql, format: 'JSON+COLUMNNAME' }); const { Table } = await fetch(SDA, { method: 'POST', body }).then(r => r.json()); const [headers, , ...rows] = Table; el.innerHTML = ` <p style="color:#22c55e">✓ ${rows.length} rows</p> <div style="overflow-x:auto"> <table class="result-table"> <thead><tr> ${headers.map(h => `<th>${h}</th>`).join('')} </tr></thead> <tbody> ${rows.slice(0, 50).map(r => `<tr>${r.map(c => `<td>${c ?? '—'}</td>`).join('')}</tr>` ).join('')} </tbody> </table> </div>`; } catch(e) { el.innerHTML = `<p style="color:red">✗ ${e.message}</p>`; } } // Call it: renderTable( "SELECT TOP 20 areasymbol, areaname FROM legend WHERE areasymbol LIKE 'WI%'", 'my-container' );
Color a soil map from live interpretation data
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>// Query FSI ratings, then color a county fill layer async function loadFSILayer(map, areasymbol) { const body = new URLSearchParams({ query: ` SELECT mu.musym, ci.interphrc AS fsi_class, ci.interphr AS fsi_score FROM legend l INNER JOIN mapunit mu ON mu.lkey = l.lkey INNER JOIN component c ON c.mukey = mu.mukey INNER JOIN cointerp ci ON ci.cokey = c.cokey WHERE l.areasymbol = '${areasymbol}' AND c.majcompflag = 'Yes' AND ci.mrulename = 'Fragile Soil Index' AND ci.ruledepth = 0 ORDER BY c.comppct_r DESC `, format: 'JSON+COLUMNNAME' }); const [headers, , ...rows] = (await fetch(SDA, {method:'POST',body}).then(r=>r.json())).Table; // Build lookup: musym → { fsi_class, fsi_score } const lookup = {}; rows.forEach(row => { const obj = Object.fromEntries(headers.map((h,i) => [h, row[i]])); if (!lookup[obj.musym]) lookup[obj.musym] = obj; }); console.log(`Loaded ${Object.keys(lookup).length} FSI ratings for ${areasymbol}`); return lookup; } function fsiToColor(cls) { const colors = { 'Not fragile': '#1b7837', 'Slightly fragile': '#78c679', 'Moderately fragile': '#fecc5c', 'Fragile': '#fd8d3c', 'Highly fragile': '#f03b20', 'Extremely fragile': '#7b0007', }; return colors[cls] || '#343450'; }
A drop-in client for any web project
/** * SDAClient — minimal, reusable SDA REST client * Drop into any web project. Zero dependencies. */ const SDAClient = (() => { const ENDPOINT = 'https://sdmdataaccess.sc.egov.usda.gov/Tabular/post.rest'; const cache = new Map(); async function query(sql, { useCache = true } = {}) { const key = sql.trim().toLowerCase(); if (useCache && cache.has(key)) return cache.get(key); const body = new URLSearchParams({ query: sql, format: 'JSON+COLUMNNAME' }); const resp = await fetch(ENDPOINT, { method: 'POST', body }); if (!resp.ok) throw new Error(`SDA HTTP ${resp.status}`); const data = await resp.json(); if (!data.Table || data.Table.length < 2) return []; const [headers, , ...rows] = data.Table; const result = rows.map(r => Object.fromEntries(headers.map((h,i) => [h, r[i]]))); if (useCache) cache.set(key, result); return result; } async function dominantComponents(areasymbol) { return query(` SELECT mu.musym, mu.muname, mu.muacres, c.compname, c.comppct_r, c.drainagecl, c.hydgrp, c.taxclname FROM legend l INNER JOIN mapunit mu ON mu.lkey = l.lkey INNER JOIN component c ON c.mukey = mu.mukey WHERE l.areasymbol = '${areasymbol}' AND c.majcompflag = 'Yes' ORDER BY mu.muacres DESC, c.comppct_r DESC `); } async function horizons(areasymbol) { return query(` SELECT mu.musym, c.compname, ch.hzname, ch.hzdept_r, ch.hzdepb_r, ch.om_r, ch.awc_r, ch.ph1to1h2o_r FROM legend l INNER JOIN mapunit mu ON mu.lkey = l.lkey INNER JOIN component c ON c.mukey = mu.mukey INNER JOIN chorizon ch ON ch.cokey = c.cokey WHERE l.areasymbol = '${areasymbol}' AND c.majcompflag = 'Yes' ORDER BY mu.musym, c.comppct_r DESC, ch.hzdept_r `); } return { query, dominantComponents, horizons }; })(); // Usage const comps = await SDAClient.dominantComponents('MN003'); console.log(`${comps.length} dominant components`);
COMPANION_BASE constant) is what powers the Soil Atlas — all live soil profile queries use a version of this module.Animated horizon visualization from a live SDA query
This function renders an animated soil profile card — horizon bars colored by depth and OM, with AWC values — for any clicked map unit. This is the heart of the Soil Atlas detail panel.
async function renderSoilProfile(areasymbol, musym, container) { container.innerHTML = '<p style="color:#38bdf8">⟳ Loading profile…</p>'; const body = new URLSearchParams({ format: 'JSON+COLUMNNAME', query: ` SELECT c.compname, c.comppct_r, c.drainagecl, ch.hzname, ch.hzdept_r, ch.hzdepb_r, ch.om_r, ch.awc_r, ch.sandtotal_r, ch.claytotal_r FROM legend l INNER JOIN mapunit mu ON mu.lkey = l.lkey INNER JOIN component c ON c.mukey = mu.mukey INNER JOIN chorizon ch ON ch.cokey = c.cokey WHERE l.areasymbol = '${areasymbol}' AND mu.musym = '${musym}' AND c.majcompflag = 'Yes' ORDER BY c.comppct_r DESC, ch.hzdept_r `}); const [headers, , ...rows] = (await fetch(SDA,{method:'POST',body}).then(r=>r.json())).Table; const data = rows.map(r => Object.fromEntries(headers.map((h,i)=>[h,r[i]]))); if (!data.length) { container.innerHTML='No data'; return; } const maxDepth = Math.max(...data.map(d => +(d.hzdepb_r)||0)); const hzColors = ['#2d1a06','#4a3018','#6b4a28','#8b6040','#a07848']; const comp = data[0]; container.innerHTML = ` <div style="font-family:monospace;font-size:12px;background:#0a0e1a; border-radius:8px;padding:16px"> <div style="color:#c8a84b;font-weight:700;margin-bottom:8px"> ${musym} · ${comp.compname} (${comp.comppct_r}%) · ${comp.drainagecl} </div> ${data.map((d, i) => { const thick = (+d.hzdepb_r - +d.hzdept_r)||0; const pct = (thick / maxDepth * 100).toFixed(0); return ` <div style="display:flex;align-items:center;gap:8px;margin-bottom:3px"> <span style="width:56px;color:#5a6490;text-align:right;font-size:10px"> ${d.hzdept_r}–${d.hzdepb_r} cm </span> <div style="flex:1;background:#181e30;border-radius:2px;height:22px;overflow:hidden"> <div class="hz-anim" style="background:${hzColors[i%hzColors.length]}; height:100%;width:0;display:flex;align-items:center;padding:0 8px; transition:width 1s ease" data-w="${pct}"> <span style="color:rgba(255,255,255,.8);font-size:10px;white-space:nowrap"> ${d.hzname} · OM ${d.om_r||'—'}% </span> </div> </div> <span style="width:48px;color:#c8a84b;font-size:10px;text-align:right"> ${d.awc_r ? d.awc_r+' AWC' : '—'} </span> </div>`; }).join('')} </div>`; // Animate bars setTimeout(() => container.querySelectorAll('.hz-anim').forEach( b => b.style.width = b.dataset.w + '%' ), 100); }
Run any SQL query live against SDA in a full-screen sandbox — with example queries, syntax highlighting, and CSV export.
Open Query Lab →