Building a course roster dashboard over a third-party public API
Express BFF plus React UI over a creator-platform Public API — published courses, active enrollments, parallel user hydration, and why API-native filters beat application-tier filtering.
- TypeScript
- React
- Express
- API design
Building a course roster dashboard over a third-party public API
Third-party APIs rarely hand you the shape your UI needs. I built an Express BFF + React dashboard that joins published courses with active enrollments and hydrated student names — and learned that filtering belongs upstream, not in the browser.
#TL;DR
I built an Express BFF + React dashboard that calls a creator-platform Public API and lists published courses with each course’s name, heading, and active enrollments (student name + email).
| Layer | Responsibility |
|---|---|
| BFF service | GET /courses, filter is_published, fan out enrollments + users |
GET /api/courses | Aggregated CourseWithStudents[] for the UI |
| React | Horizontal course cards; no client-side published filter |
| Tests | Jest on filtering, retries, and error paths |
This post is about integration design and developer experience — how to aggregate upstream resources without leaking draft metadata to the browser.
#What the dashboard had to show
Minimum bar:
- Fetch published courses in a school
- For each: name, heading, list of actively enrolled students with name and email
#Data flow
Browser → GET /api/courses (Express BFF)
→ GET /courses (upstream)
→ filter is_published (application tier)
→ per course: enrollments → parallel user lookups
→ merge → JSON to ReactThe controversial filter runs after listing all courses:
const publishedCourses = response.courses.filter(
(course) => course.is_published,
);Architecturally that is BFF-tier filtering, not React filtering. The better design — when the upstream API documents it — is a query parameter on GET /courses so draft rows never cross the wire. Microsoft’s API design guidance on filtering collections describes the same principle: push predicates to the origin when possible.
#The lesson that stuck
The biggest surprise at the time: filtering and sorting belong on the API, not in the React layer. With ~75 test courses in the environment, client-side filter() felt fine — but that mindset does not scale. Pulling only is_published=true rows upstream saves bandwidth, keeps pagination honest, and avoids leaking draft metadata to the browser. That correction stuck: push predicates to the origin when the upstream API documents them.
#BFF hardening
| Concern | Approach |
|---|---|
| Transient 5xx / 429 | Retries with backoff |
| Rate limits | Spacing between upstream calls |
| Browser attack surface | Helmet + CORS on Express |
| Wide course lists | Summary stats + horizontal scroll affordances |
#Upstream client mechanics (what the service actually does)
The BFF wraps the Public API with a dedicated service class (typed fetch layer, not hand-rolled axios in routes):
| Constant | Value | Effect |
|---|---|---|
API_TIMEOUT | 10 s | AbortController abort → 408 on hung sockets |
MAX_RETRIES | 3 | Retries on network errors, 5xx, and 429 |
RETRY_DELAY | 1 s base | Exponential backoff + jitter, capped at 30 s |
| Rate spacing | 50 ms minimum | Doubles toward 1 s after burst (requestCount > 10) |
Retry decision (simplified):
private shouldRetry(error: unknown): boolean {
if (isNetworkError(error)) return true;
if (error instanceof ApiError) {
return error.status >= 500 || error.status === 429;
}
return false;
}#Enrollment fan-out: parallel users, sequential courses
getPublishedCoursesWithStudents() is a two-level fan-out:
- List all courses → filter
is_publishedin the BFF (the lesson from ~75 test rows). - For each published course,
GET …/enrollments, dedupeuser_id, thenPromise.allSettledon per-userGET …/users/:id.
Partial failure is intentional: a rejected user lookup logs a warning and drops that enrollment instead of failing the whole dashboard. Empty enrollments short-circuit without user calls.
The outer loop is sequential per course — fine for a school-sized roster, but a production variant would batch courses or cap concurrency to protect upstream rate limits.
#HTTP contract to the React app
GET /api/courses returns an envelope the UI can assert in tests:
{
"success": true,
"data": [{ "course": { … }, "students": [ … ] }],
"meta": { "total_courses": 12, "total_students": 84 }
}/api/courses/summary flattens the same aggregation for lighter clients. Both routes share the same service method — duplication is only at the JSON shape layer.
#Runnable path vs correct JSON
A monorepo README that says npm install && npm run dev at the root is misleading when:
- Create React App lives in
src/client/with its ownpackage.json REACT_APP_API_URLmust exist where CRA reads env vars (src/client/.env)
You can pass server unit tests and still fail to boot the UI — which means setup documentation is part of the contract, same as response shape.
#Closing thought
Correct aggregation with a broken onboarding path is half a system. On third-party APIs, use documented query filters first, then aggregate — and document every install directory the way you document every route.
#Reader field guide
When a BFF over a public API makes sense: The upstream API exposes multiple resources (courses, enrollments, users); the UI needs a joined shape; rate limits and retries belong server-side; the browser should not see draft rows.
Operational checklist
- Prefer upstream
is_published(or equivalent) query params over BFFfilter()when documented - Wrap upstream calls with timeout, exponential backoff, and 429/5xx retry policy
- Use
Promise.allSettledon user hydration — drop failed lookups, do not fail the whole dashboard - Cap concurrency on course fan-out if roster size grows beyond school-scale
- Document nested client paths (
src/client/) and env vars (REACT_APP_API_URL) in the root README - Return a stable envelope (
success,data,meta) so UI and tests share one contract - Helmet + CORS on Express before exposing aggregated data
#Related reading
#On this site
| Post | Why |
|---|---|
| First MERN stack production deploy | Earlier lesson in one-origin Express + static client hosting |
| Team MERN exam workflow capstone | Monorepo client patterns after this focused BFF exercise |
| Cutting a data API from 21s to ~250ms | When aggregation belongs in SQL/OLTP, not in Node loops |
#References (curated)
The third-party API’s filter docs matter more than our dashboard UI—most bugs were “filter in the wrong tier.”
| Reference | Notes |
|---|---|
| Express security best practices | Helmet, rate limits, and error shapes on the BFF. |
| Jest — mocking HTTP | How we tested retries without hitting live rate limits in CI. |
| HTTP 429 semantics | Backoff + jitter when the upstream throttles enrollment pulls. |
| React Testing Library | Dashboard tests that assert loading and empty states, not implementation details. |