The reporting API sits between the database and the dashboard. It translates SQL queries into JSON endpoints that the front-end can consume. It also handles authentication — only authorized users should see analytics data.
You could embed SQL queries directly in the dashboard, but that couples your front-end to your database schema. A separate API layer creates a clean boundary: the dashboard is pure client-side JavaScript that fetches JSON, and the API handles all data access and authentication on the server.
The dashboard should not know that pageviews are stored in a MySQL table called pageviews, that timestamps are in the server_timestamp column, or that session duration requires a TIMESTAMPDIFF calculation. The dashboard should ask "give me pageviews for the last 7 days" and receive a clean JSON response. If you later switch from MySQL to PostgreSQL, or restructure your tables, or add caching — the dashboard does not change.
An API can serve more than one consumer. Today it powers your web dashboard. Tomorrow it could power a mobile app, a CLI tool for quick checks, a Slack bot that posts daily summaries, or an export script that generates CSV reports. Every consumer speaks the same JSON protocol. Without an API, each client would need its own database connection and query logic.
curl or a test framework without loading a browser. Input validation, authentication, and query correctness are all testable in isolation.The API follows RESTful conventions: resources are nouns, HTTP methods indicate the action, and responses use standard status codes. Every analytics endpoint uses GET because dashboards only read data. The only POST endpoints are for authentication.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/dashboard |
Summary metrics: total pageviews, total sessions, average load time, error count. Used by the dashboard's top-level overview cards. |
GET |
/api/pageviews |
Pageviews over time (grouped by day or hour) and top pages by view count. Powers the main traffic chart and "Top Pages" table. |
GET |
/api/performance |
Web Vitals percentiles (p50, p75, p95) and load time distributions. Powers the performance panel with LCP, CLS, and INP breakdowns. |
GET |
/api/errors |
Error frequency over time and top error messages. Powers the error tracking panel with trend lines and grouped error counts. |
GET |
/api/sessions |
Session statistics: total sessions, average duration, average pages per session, bounce rate. Powers the audience overview. |
POST |
/api/login |
Authenticate a user with email and password. Returns a session cookie on success. |
POST |
/api/logout |
Destroy the current session. Clears the session cookie. |
This is a small, focused API. Each analytics endpoint maps to one panel in the dashboard. The /api/dashboard endpoint is a convenience route that aggregates the summary numbers from several tables into a single response, so the dashboard can populate its overview cards with one request instead of four.
/api/analytics endpoint? Separate endpoints let the dashboard fetch only the data it needs. When a user navigates to the performance tab, the dashboard calls /api/performance without loading pageview or error data. This reduces response size, server load, and page latency.
Every analytics endpoint accepts query parameters for date filtering, pagination, and grouping. These parameters are consistent across all GET endpoints so the dashboard can apply the same date picker and pagination controls everywhere.
All analytics endpoints accept start and end parameters in YYYY-MM-DD format. If omitted, the API defaults to the last 7 days.
GET /api/pageviews?start=2026-02-01&end=2026-02-14
Endpoints that return lists (top pages, error messages) accept page and limit parameters. The default is page 1 with 20 results per page.
GET /api/errors?page=2&limit=10
Time-series endpoints accept a group parameter to control the granularity of the data points. Valid values are day and hour. The default is day.
GET /api/pageviews?start=2026-02-10&end=2026-02-11&group=hour
// Request
GET /api/pageviews?start=2026-02-01&end=2026-02-07&group=day&page=1&limit=5
// Response
{
"success": true,
"data": {
"timeseries": [
{ "date": "2026-02-01", "views": 1243 },
{ "date": "2026-02-02", "views": 987 },
{ "date": "2026-02-03", "views": 1102 },
{ "date": "2026-02-04", "views": 1455 },
{ "date": "2026-02-05", "views": 1321 },
{ "date": "2026-02-06", "views": 1189 },
{ "date": "2026-02-07", "views": 1067 }
],
"top_pages": [
{ "url": "/", "views": 2847 },
{ "url": "/about", "views": 1203 },
{ "url": "/blog", "views": 891 },
{ "url": "/contact", "views": 654 },
{ "url": "/pricing", "views": 512 }
]
},
"meta": {
"total": 23,
"page": 1,
"limit": 5
}
}
limit=999999 or start=1970-01-01. Cap limit at a reasonable maximum (100), validate date formats, and reject ranges longer than your retention period. Never pass user input directly into SQL.
Every API response uses a consistent JSON envelope. The dashboard can rely on the same structure regardless of which endpoint it calls. This makes error handling predictable and simplifies the front-end code.
{
"success": true,
"data": { ... },
"meta": {
"total": 142,
"page": 1,
"limit": 20
}
}
The data field contains the actual payload — its shape varies by endpoint. The meta field contains pagination information. For endpoints that do not paginate (like /api/dashboard), meta can be omitted or empty.
{
"success": false,
"error": "Invalid date range: start must be before end"
}
The API returns proper HTTP status codes alongside the JSON body. The front-end should check the status code first, then parse the body.
| Status | Meaning | When |
|---|---|---|
200 |
OK | Request succeeded, data returned |
400 |
Bad Request | Invalid query parameters (bad date format, negative page number) |
401 |
Unauthorized | No session cookie or session expired |
403 |
Forbidden | Authenticated but insufficient role (viewer trying to manage users) |
429 |
Too Many Requests | Rate limit exceeded |
500 |
Internal Server Error | Database error, unhandled exception |
success field when we have status codes? Because front-end developers sometimes forget to check response.ok and go straight to parsing JSON. The success boolean in the body provides a second safety net. It also makes the response self-describing when logged or inspected in browser DevTools.
The reporting API uses session-based authentication. The user logs in with email and password, the server validates the credentials, creates a server-side session, and sends back a cookie. Every subsequent request includes the cookie automatically — no token management needed on the client.
POST /api/login request with email and password in the request body.users table.bcrypt.compare() (Node.js) or password_verify() (PHP).Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict.| Flag | Purpose |
|---|---|
HttpOnly |
Prevents JavaScript from reading the cookie via document.cookie. Mitigates XSS attacks that try to steal session IDs. |
Secure |
Cookie is only sent over HTTPS. Prevents session hijacking on insecure networks. |
SameSite=Strict |
Cookie is not sent on cross-site requests. Prevents CSRF attacks where a malicious site tricks the browser into making authenticated requests to your API. |
bcrypt (Node.js) or password_hash() with PASSWORD_BCRYPT (PHP). These functions produce a one-way hash that cannot be reversed. The compare/verify functions handle salt extraction and timing-safe comparison automatically.
Not every dashboard user should have the same access. The users table includes a role column with three possible values, each granting a different level of access.
| Role | View Analytics | Manage Users | System Settings |
|---|---|---|---|
viewer |
Yes | No | No |
admin |
Yes | Yes | No |
owner |
Yes | Yes | Yes |
Role enforcement happens in middleware that runs before every route handler. The middleware reads the session, extracts the user's role, and compares it against the required role for the requested endpoint. If the role is insufficient, the middleware returns a 403 Forbidden response before the route handler ever executes.
// Middleware: require authentication
function requireAuth(req, res, next) {
if (!req.session || !req.session.userId) {
return res.status(401).json({
success: false,
error: 'Authentication required'
});
}
next();
}
// Middleware: require a minimum role level
function requireRole(minRole) {
const hierarchy = { viewer: 1, admin: 2, owner: 3 };
return (req, res, next) => {
const userRole = req.session.role;
if (hierarchy[userRole] < hierarchy[minRole]) {
return res.status(403).json({
success: false,
error: 'Insufficient permissions'
});
}
next();
};
}
// Usage in routes:
app.get('/api/pageviews', requireAuth, getPageviews);
app.post('/api/users', requireAuth, requireRole('admin'), createUser);
app.delete('/api/users/:id', requireAuth, requireRole('admin'), deleteUser);
Rate limiting prevents a single client from overwhelming the API with too many requests. Without it, a bug in the dashboard (an infinite loop calling fetch) or a malicious script can saturate the server and degrade service for everyone.
The simplest approach: maintain a Map keyed by client IP address, tracking the number of requests made in the current minute. When the count exceeds the limit, return 429 Too Many Requests.
// Simple in-memory rate limiter
const rateLimitMap = new Map();
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 60; // 60 requests per minute per IP
function rateLimit(req, res, next) {
const ip = req.ip;
const now = Date.now();
if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, { count: 1, windowStart: now });
return next();
}
const entry = rateLimitMap.get(ip);
// Reset window if expired
if (now - entry.windowStart > WINDOW_MS) {
entry.count = 1;
entry.windowStart = now;
return next();
}
entry.count++;
if (entry.count > MAX_REQUESTS) {
return res.status(429).json({
success: false,
error: 'Too many requests. Try again in a minute.'
});
}
next();
}
// Apply to all API routes
app.use('/api', rateLimit);
Well-behaved rate limiters include headers that tell the client how many requests remain and when the window resets:
HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1740000060
express-rate-limit (Node.js) or throttle middleware (PHP) handle these edge cases. For multi-server deployments, store rate-limit counters in Redis so all instances share the same counts.
The API-first approach dictates that the dashboard is a single-page application (SPA). The server serves one HTML file (plus CSS and JavaScript), and all data comes from API calls. The server never renders HTML for dashboard pages.
https://example.com/dashboard.index.html file with a <script> tag.GET /api/dashboard), and renders the UI.fetch() call to the appropriate API endpoint and re-renders that section.This is the pattern used by virtually every modern analytics tool. Google Analytics, Plausible, PostHog, Mixpanel — all of them serve a static SPA that consumes a JSON API. The dashboard you build in the next phase follows the same approach.
/api/login and /api/logout endpoints. The key constraint is that the API never returns HTML — it only returns JSON. All rendering happens in the browser.
Build the reporting API with Express.js: route handlers for each analytics endpoint, session-based authentication with bcrypt, role middleware, and rate limiting.
Start Module →Build the same reporting API in PHP: PDO database queries, native session handling, password_hash/password_verify authentication, and JSON response formatting.
Start Module →