Wat je gaat bouwen
Een staafgrafiek met de uurprijzen van vandaag. Daaronder staat dezelfde data als tabel. Je browser haalt de data op bij je eigen server; die vraagt de stroomprijzen op bij de API. Zo heb je geen last van cross-originverzoeken en hoeft je API-key nooit in de browser te staan.
Je hebt Node.js 22 of nieuwer nodig en twee bestanden: index.html en server.mjs. Chart.js wordt in het voorbeeld geladen vanaf een CDN, op de vaste versie 4.5.1. De basis volgt de officiële Chart.js-documentatie.
Dit voorbeeld is voor een persoonlijke, niet-commerciële website. De bronvermelding blijft zichtbaar onder de grafiek. Gebruik je de API in een commercieel product, bespreek dan eerst een licentie.
1. Download de bestanden en start de server
Zet beide bestanden in dezelfde map. Je hoeft voor dit voorbeeld geen npm-packages te installeren.
Open een terminal in die map en voer uit:
export STROOMPRIJZEN_API_KEY='jouw-key'
node server.mjsOpen daarna http://127.0.0.1:3000 in je browser. Open het HTML-bestand niet rechtstreeks als bestand: de route voor de prijsdata wordt door de Node-server aangeboden.
Maak eerst een gratis API-key voor 10.000 requests per dag. Stel STROOMPRIJZEN_API_KEY in via de omgeving of secretmanager van je server. Het voorbeeld leest geen .env-bestand automatisch.
2. Haal de prijsdata op in je backend
De backend kiest de datum in Europe/Amsterdam en vraagt /api/v1/prices op met resolution=hour. Alle bezoekers gebruiken één gedeeld antwoord dat vijf minuten wordt bewaard. Gelijktijdige aanvragen delen dezelfde lopende fetch.
Een bezoeker kan geen eigen upstream-URL of key meegeven. De browser gebruikt alleen de vaste route /api/stroomprijzen. Een geldige API-key is verplicht. De key blijft op je server en wordt nooit naar bezoekers gestuurd.
Bekijk de complete server.mjs
// Node.js 22+. Keep this file next to index.html. Start with: node server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const apiKey = process.env.STROOMPRIJZEN_API_KEY;
if (!apiKey) throw new Error('Stel STROOMPRIJZEN_API_KEY in. Maak gratis een key op https://stroomprijzenapi.nl/api-key.');
const html = await readFile(new URL('./index.html', globalThis._importMeta_.url));
const dutchDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam', year: 'numeric', month: '2-digit', day: '2-digit' });
let cache;
let pending;
async function pricesForToday() {
const parts = Object.fromEntries(dutchDate.formatToParts(new Date()).map(part => [part.type, part.value]));
const day = `${parts.year}-${parts.month}-${parts.day}`;
if (cache?.day === day && cache.until > Date.now()) return cache.data;
if (pending?.day === day) return pending.promise;
const promise = (async () => {
const url = new URL('https://stroomprijzenapi.nl/api/v1/prices');
url.searchParams.set('date', day);
url.searchParams.set('resolution', 'hour');
// The key stays on this server, never in HTML or browser storage.
const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` };
const response = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
if (!response.ok) throw Object.assign(new Error('Prijsdata ophalen mislukt.'), { statusCode: response.status === 429 ? 429 : 502 });
const data = await response.json();
if (!Array.isArray(data.points) || data.date !== day) throw new Error('Ongeldige prijsresponse.');
// Five minutes, one shared entry for all visitors. Empty days are retried too.
cache = { day, data, until: Date.now() + 5 * 60 * 1000 };
return data;
})();
pending = { day, promise };
try { return await promise; }
finally { if (pending?.promise === promise) pending = undefined; }
}
createServer(async (request, response) => {
response.setHeader('X-Content-Type-Options', 'nosniff');
const path = new URL(request.url, 'http://localhost').pathname;
if (request.method !== 'GET') {
response.writeHead(405, { Allow: 'GET' }); response.end(); return;
}
if (path === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); response.end(html); return;
}
if (path !== '/api/stroomprijzen') { response.writeHead(404); response.end('Niet gevonden.'); return; }
// Only this fixed upstream endpoint is reachable; visitors cannot supply URLs or keys.
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.setHeader('Cache-Control', 'no-store');
try { response.end(JSON.stringify(await pricesForToday())); }
catch (error) {
response.statusCode = error.statusCode === 429 ? 429 : 502;
if (response.statusCode === 429) response.setHeader('Retry-After', '300');
response.end(JSON.stringify({ error: response.statusCode === 429 ? 'De API-limiet is bereikt. Probeer later opnieuw.' : 'De stroomprijzen zijn tijdelijk niet beschikbaar.' }));
}
}).listen(Number(process.env.CHART_DEMO_PORT || 3000), process.env.CHART_DEMO_HOST || '127.0.0.1', () => {
console.log('Grafiekserver gestart. Open het ingestelde lokale adres (standaard http://127.0.0.1:3000).');
});
Bij een netwerkfout geeft de backend een duidelijke foutmelding terug. Een nog lege dag wordt kort bewaard en daarna opnieuw opgevraagd. Er worden geen fictieve nulprijzen ingevuld.
3. Zet de prijzen in Chart.js
De frontend leest points uit de JSON-response. Elk punt levert één staaf in de grafiek op. We sorteren op momentUtc en gebruiken de prijs inclusief belastingen: allInEurKwh.
centPerKwh = allInEurKwh * 100De prijs is in euro per kWh. Voor de verticale as in centen vermenigvuldig je dus met 100. Negatieve waarden blijven negatief en krijgen een andere kleur.
Bekijk de complete index.html
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Stroomprijzen vandaag met Chart.js</title>
<style>
body { max-width: 960px; margin: 40px auto; padding: 0 20px; font: 16px/1.6 system-ui, sans-serif; color: #15312a; }
h1 { font-size: clamp(28px, 5vw, 42px); line-height: 1.15; }
.chart { position: relative; height: 380px; }
table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
button { padding: 10px 16px; cursor: pointer; } a { color: #08713b; }
</style>
</head>
<body>
<h1>Stroomprijzen van vandaag</h1>
<p>Per kWh, inclusief energiebelasting en btw. Leveranciersopslag, netbeheer en vaste kosten zijn niet inbegrepen.</p>
<p id="status" role="status">Prijzen laden…</p>
<button id="refresh" type="button">Opnieuw ophalen</button>
<div class="chart"><canvas id="prices" role="img" aria-label="Stroomprijs per uur in cent per kWh. Dezelfde waarden staan in de tabel hieronder."></canvas></div>
<details><summary>Bekijk de prijzen als tabel</summary><table><thead><tr><th scope="col">Nederlandse tijd</th><th scope="col">Prijs per kWh</th></tr></thead><tbody id="rows"></tbody></table></details>
<p>Data: <a href="https://stroomprijzenapi.nl">StroomprijzenAPI.nl</a> / Stroomwekker.nl. Marktdata: <a href="https://www.smard.de">Bundesnetzagentur | SMARD.de</a>, <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.</p>
<noscript>Deze grafiek heeft JavaScript nodig.</noscript>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script type="module">
const status = document.querySelector('#status');
const button = document.querySelector('#refresh');
const time = new Intl.DateTimeFormat('nl-NL', { timeZone: 'Europe/Amsterdam', hour: '2-digit', minute: '2-digit', timeZoneName: 'shortOffset' });
const euros = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR', maximumFractionDigits: 6 });
let chart;
async function load() {
button.disabled = true;
status.textContent = 'Prijzen laden…';
try {
const response = await fetch('/api/stroomprijzen');
if (!response.ok) throw new Error(response.status === 429 ? 'De API-limiet is bereikt. Probeer later opnieuw.' : 'Prijzen ophalen lukt nu niet. Probeer het later opnieuw.');
const data = await response.json();
if (!Array.isArray(data.points) || !data.points.every(p => Number.isFinite(p.allInEurKwh) && Number.isFinite(Date.parse(p.momentUtc)))) throw new Error('De prijsdata is niet geldig.');
const points = [...data.points].sort((a, b) => Date.parse(a.momentUtc) - Date.parse(b.momentUtc));
if (points.length && typeof Chart === 'undefined') throw new Error('Chart.js is niet geladen. Controleer je verbinding en probeer opnieuw.');
const labels = points.map(p => time.format(new Date(p.momentUtc)));
const cents = points.map(p => p.allInEurKwh * 100);
const rows = points.map((point, i) => {
const row = document.createElement('tr');
for (const value of [labels[i], euros.format(point.allInEurKwh)]) {
const cell = document.createElement('td'); cell.textContent = value; row.append(cell);
}
return row;
});
if (chart) { chart.destroy(); chart = undefined; }
document.querySelector('#rows').replaceChildren(...rows);
if (!points.length) { status.textContent = 'Voor deze dag zijn nog geen prijzen gepubliceerd.'; return; }
chart = new Chart(document.querySelector('#prices'), {
type: 'bar',
data: { labels, datasets: [{ label: 'Prijs incl. belasting (ct/kWh)', data: cents, backgroundColor: cents.map(value => value < 0 ? '#db4563' : '#079756') }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { title: { display: true, text: 'Cent per kWh, incl. belasting' } } }, plugins: { tooltip: { callbacks: { label: item => item.parsed.y.toLocaleString('nl-NL', { maximumFractionDigits: 4 }) + ' ct/kWh' } } } }
});
status.textContent = `${data.date}: ${points.length} prijsintervallen. Tijdzone: Europe/Amsterdam.`;
} catch (error) { status.textContent = error.message + (chart ? ' De eerder opgehaalde grafiek blijft zichtbaar.' : ''); }
finally { button.disabled = false; }
}
button.addEventListener('click', load);
load();
</script>
</body>
</html>
De knop haalt de gegevens opnieuw op. Bij een tijdelijke fout blijft de laatste geldige grafiek zichtbaar met een melding. De tabel maakt de waarden ook zonder het aflezen van staafhoogtes toegankelijk.
Vier dingen die je grafiek correct houden
Gebruik de juiste prijs
allInEurKwh bevat de marktprijs, energiebelasting, historische ODE en btw. Leveranciersopslag, netbeheerkosten, vaste kosten en de jaarlijkse belastingvermindering zitten er niet in. Zet die afbakening ook bij je grafiek. De belastingtabellen laten de gebruikte tarieven zien.
Een dag heeft niet altijd 24 uur
Rond de zomertijdwissel zijn het 23 of 25 uurprijzen. Gebruik alle teruggegeven intervallen, zonder een vaste array van 24 plekken. Het voorbeeld toont Nederlandse tijd met de UTC-offset, zodat twee keer 02:00 bij wintertijd uit elkaar te houden zijn.
Leeg is geen nul
Ontbrekende data betekent niet dat stroom gratis is. De pagina toont een melding als er nog geen punten zijn. Wil je later morgen toevoegen, houd er dan rekening mee dat die prijzen meestal pas rond 14:00 Nederlandse tijd beschikbaar zijn.
Laat de bron staan
Onder de grafiek staan StroomprijzenAPI.nl / Stroomwekker.nl en de marktbron Bundesnetzagentur | SMARD.de met CC BY 4.0. Laat die verwijzingen staan als je het voorbeeld aanpast. Lees de gebruiksvoorwaarden en bronverantwoording.
Op je eigen website gebruiken
De voorbeeldserver luistert standaard alleen op je eigen computer. Voor publicatie heb je hosting nodig die Node.js kan draaien, of je neemt de vaste prijsroute over in je bestaande backend. Laat frontend en backend via hetzelfde domein bereikbaar zijn. Gebruik HTTPS en bewaar je API-key in de serveromgeving.
De bindingsopties voor deze demo zijn CHART_DEMO_PORT en CHART_DEMO_HOST. De minimale server regelt geen productiehosting of TLS voor je. Op een grotere site voeg je passend beheer en verzoeklimieten toe aan je eigen route.
Wil je kwartierprijzen? Pas in de backend resolution aan naar quarterhour. De frontend gebruikt al het aantal intervallen uit de response. Voor andere endpoints en parameters kun je verder met de quickstart en het endpointoverzicht.