5 min readCase study

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).

LayerResponsibility
BFF serviceGET /courses, filter is_published, fan out enrollments + users
GET /api/coursesAggregated CourseWithStudents[] for the UI
ReactHorizontal course cards; no client-side published filter
TestsJest 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

Text
Browser → GET /api/courses (Express BFF)
              → GET /courses (upstream)
              → filter is_published (application tier)
              → per course: enrollments → parallel user lookups
              → merge → JSON to React

The controversial filter runs after listing all courses:

TypeScript
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

ConcernApproach
Transient 5xx / 429Retries with backoff
Rate limitsSpacing between upstream calls
Browser attack surfaceHelmet + CORS on Express
Wide course listsSummary 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):

ConstantValueEffect
API_TIMEOUT10 sAbortController abort → 408 on hung sockets
MAX_RETRIES3Retries on network errors, 5xx, and 429
RETRY_DELAY1 s baseExponential backoff + jitter, capped at 30 s
Rate spacing50 ms minimumDoubles toward 1 s after burst (requestCount > 10)

Retry decision (simplified):

TypeScript
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:

  1. List all courses → filter is_published in the BFF (the lesson from ~75 test rows).
  2. For each published course, GET …/enrollments, dedupe user_id, then Promise.allSettled on per-user GET …/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:

JSON
{
  "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 own package.json
  • REACT_APP_API_URL must 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 BFF filter() when documented
  • Wrap upstream calls with timeout, exponential backoff, and 429/5xx retry policy
  • Use Promise.allSettled on 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

#On this site

PostWhy
First MERN stack production deployEarlier lesson in one-origin Express + static client hosting
Team MERN exam workflow capstoneMonorepo client patterns after this focused BFF exercise
Cutting a data API from 21s to ~250msWhen 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.”

ReferenceNotes
Express security best practicesHelmet, rate limits, and error shapes on the BFF.
Jest — mocking HTTPHow we tested retries without hitting live rate limits in CI.
HTTP 429 semanticsBackoff + jitter when the upstream throttles enrollment pulls.
React Testing LibraryDashboard tests that assert loading and empty states, not implementation details.