Power BI covers most internal reporting well, and I say that as someone who builds semantic models for a living. But there is a class of dashboards where the packaged tool is the wrong shape: a customer-facing portal that must match your product’s design system, an operations wall that refreshes every ten seconds, a visualization that no chart library draws, or a public page where per-viewer licensing makes no sense. For those, a small custom stack is faster to build and cheaper to run than bending a BI tool into something it is not.
When to build instead of buy
| Signal | Build custom | Stay in Power BI |
|---|---|---|
| Audience | External customers, the public, or thousands of unlicensed users | Internal staff with Microsoft 365 licenses |
| Refresh | Sub-minute, streaming, or event-driven | Hourly or daily refresh is fine |
| Visual form | Bespoke: network graphs, custom maps, animated transitions, novel encodings | Standard charts, matrices, KPI cards |
| Design | Must match a product brand pixel for pixel | Corporate theme is enough |
| Interaction | Write-back forms, workflows, complex drill paths | Slice, drill, bookmark |
| Governance | You can own an application lifecycle | You want the service to handle security, refresh and distribution |
A middle path exists: Power BI embedded analytics lets you put Power BI reports inside your own application for customers, with capacity-based licensing rather than per-user licenses [1]. If your need is standard visuals on a custom page, embed first. Build custom when the visuals themselves are the problem.
Reference architecture
The stack below is deliberately boring. Every piece is open source, well documented and hireable.
- Data layer: SQL Server or a lakehouse gold layer. The dashboard never queries raw tables. It reads from views or a serving schema that already applies the business definitions, so the custom dashboard and the Power BI report agree.
- Analytics service: Python. Forecasts, scoring and heavy aggregation run in a small Python service (FastAPI or Flask) with pandas and scikit-learn, cached by key and refreshed on a schedule. The web tier never computes statistics.
- API: Node.js. Node.js is an open-source, cross-platform JavaScript runtime whose non-blocking I/O model suits many concurrent, short requests [2], which is exactly a dashboard’s traffic pattern. The API exposes typed JSON endpoints, enforces authentication and row-level filtering, and rate-limits.
- Layout: HTML5, CSS3 and Tailwind. CSS Grid gives you a dashboard grid in a few lines [3]; Tailwind’s utility classes keep the styling in the markup and consistent across screens [4]. No CSS framework fights you less on a dense, data-heavy page.
- Visualization: D3. D3 is a low-level toolbox for bespoke visualization on web standards (SVG, Canvas, HTML), not a charting library [5]. That is the point: when you need a chart nobody ships, D3 is how you draw it. For standard charts, wrap D3 in a few reusable components and do not reinvent axes every time.
- Rendering strategy. SVG for a few thousand marks with crisp text and accessibility; Canvas when you are drawing tens of thousands of points or animating at 60 frames per second [6].
A minimal example
The API returns a tidy series; the client draws it. Everything else is layout.
// server.js (Node.js + Express): one endpoint, parameterized, cached
app.get('/api/volume', auth, async (req, res) => {
const { service, from, to } = req.query;
const rows = await db.query(
'SELECT [Month], [CBM] FROM gold.vw_monthly_volume WHERE Service=@s AND [Month] BETWEEN @f AND @t',
{ s: service, f: from, t: to });
res.set('Cache-Control', 'private, max-age=300').json(rows);
});
// client.js (D3 v7): draw a line with a 2px stroke and labelled end point
const svg = d3.select('#chart').append('svg').attr('viewBox', '0 0 640 320');
const x = d3.scaleUtc().domain(d3.extent(data, d => d.Month)).range([48, 600]);
const y = d3.scaleLinear().domain([0, d3.max(data, d => d.CBM)]).nice().range([280, 24]);
svg.append('path').datum(data).attr('fill', 'none').attr('stroke', '#1f6fb2').attr('stroke-width', 2)
.attr('d', d3.line().x(d => x(d.Month)).y(d => y(d.CBM)));
svg.append('g').attr('transform', 'translate(0,280)').call(d3.axisBottom(x).ticks(6));
svg.append('g').attr('transform', 'translate(48,0)').call(d3.axisLeft(y).ticks(5));
Tailwind handles the page: grid grid-cols-12 gap-4 on the container, col-span-8 for the main chart, col-span-4 for the KPI column, and a dark: variant for the operations wall at night.
Rules that keep a custom dashboard honest
- One definition of every number. The API reads the same serving views the semantic model uses. If finance reconciles the Power BI report, the dashboard inherits the reconciliation.
- Security in the API, not the browser. Row filtering by tenant or customer happens server-side with the user’s identity; the client never receives rows it should not see.
- Accessibility is not optional. Follow WCAG 2 for contrast, keyboard navigation and text alternatives [7]. Every D3 chart gets a table view and ARIA labels; color is never the only encoding.
- Design-system discipline. Fixed categorical color order, one hue for magnitude, two hues plus a neutral midpoint for divergence, thin marks, a legend whenever there are two or more series. A custom chart is only better than the packaged one if it is at least as readable.
- Observe it. Log query latency per endpoint and chart render time per visual. The dashboard that nobody measures becomes the dashboard that is slow by the time anyone complains.
What it costs
A focused custom dashboard (one audience, five to eight visuals, an authenticated API, a Python service for one or two models) is a few weeks of senior work and runs on a single small virtual machine or container. The ongoing cost is ownership: dependency updates, security patches and the person who understands it. That is the real trade: you are exchanging a license fee for an application you have to maintain. Make that trade only when the packaged tool cannot do the job, and when you can name the person who will own the code in two years.
Related: advanced Power BI modeling for the semantic model the custom dashboard should read from, and what generative AI changes in BI.
References
- Microsoft Learn, “Power BI embedded analytics overview,” learn.microsoft.com/en-us/power-bi/developer/embedded/embedded-analytics-power-bi (accessed 9 Oct 2026).
- Node.js, “Introduction to Node.js,” nodejs.org/en/learn/getting-started/introduction-to-nodejs (accessed 9 Oct 2026).
- MDN Web Docs, “CSS grid layout,” developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout (accessed 9 Oct 2026).
- Tailwind CSS, “Styling with utility classes,” tailwindcss.com/docs/styling-with-utility-classes (accessed 9 Oct 2026).
- D3 by Observable, “What is D3?” d3js.org/what-is-d3 (accessed 9 Oct 2026).
- MDN Web Docs, “Canvas API,” developer.mozilla.org/en-US/docs/Web/API/Canvas_API (accessed 9 Oct 2026).
- W3C Web Accessibility Initiative, “WCAG 2 Overview,” w3.org/WAI/standards-guidelines/wcag (accessed 9 Oct 2026).
