Architecture
This page explains how Unwrap is built and why. If you want to know what file goes where, check the project structure in the README. If you want to know why it's organised that way, you're in the right place.
NOTE
Unwrap is still under active development and only a demo with mocked data is available right now.
System overview
Unwrap is a single-page application running entirely in the browser, talking to a separate backend over HTTP. The frontend is the only thing in this repository, the backend lives in its own repo and is maintained by other team members.
A few consequences of this architectural shape:
- The frontend is independently deployable. It builds to static files and serves from any static host (Vercel, Netlify, etc.).
- The backend is replaceable. Anything that speaks the documented JSON API works.
- There is no shared state between frontend and backend at runtime. They communicate only through HTTP requests.
- The Backend handles Authentication. The frontend stores no secrets, it relies on a session cookie set by the backend.
The three-layer pattern
Every page in Unwrap follows the same three-layer separation: Component → Hook → Service.
What each layer is responsible for
Service layer (src/services/) does one thing: make HTTP calls. A service function takes parameters, builds a URL, calls apiFetch, and returns a typed Promise. It knows nothing about React, caching, or UI state.
// src/services/api.ts
export async function fetchTopTracks(
timeRange?: TimeRange,
limit = 10,
offset = 0
): Promise<TopTracksResponse> {
return apiFetch('/api/top-tracks', buildParams({ timeRange, limit, offset }));
}Hook layer (src/hooks/) wraps service calls in TanStack Query. The hook decides cache keys, when to refetch, and how to retry. It returns the standardised query result shape so components don't see fetch details.
// src/hooks/useUnwrapData.ts (illustrative)
export function useTopTracks(timeRange: TimeRange) {
return useQuery({
queryKey: ['top-tracks', timeRange],
queryFn: () => fetchTopTracks(timeRange),
});
}Component layer (src/components/, src/pages/) consumes hook results and renders. It handles loading and error UI, but does no fetching, no caching, and no data transformation beyond formatting for display.
// src/components/TrackList/TrackList.tsx (illustrative)
export default function TrackList({ timeRange }: Props) {
const { data, isLoading, isError } = useTopTracks(timeRange);
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage />;
return (
<ol>
{data.topTracks.map((t) => (
<li>{t.trackName}</li>
))}
</ol>
);
}Why this pattern
| Goal | How the pattern delivers it |
|---|---|
| Single Responsibility | Each layer has exactly one job. Easy to point at and explain. |
| Testability | Services are pure functions. Hooks are testable in isolation with QueryClient wrappers. Components are testable with MSW intercepting fetches at the network layer. |
| Replaceability | Swap the service to point at a different backend without touching components. |
| Predictable mental model | Every page works the same way. |
State management
State in Unwrap is split between two libraries based on a single question: does this state come from the server, or does it belong to the client?
Server state — TanStack Query
Anything fetched from the backend is server state: top tracks, user profile, connected services, listening time. This data:
- Can become stale (someone uploaded new data on another tab).
- Has loading and error states by definition.
- Benefits from caching, deduplication, and background refetching.
TanStack Query handles all of this. Components consume { data, isLoading, isError } and don't worry about how the data got there.
Client state — Zustand
Anything that exists only in the client and has no server counterpart is client state. Two stores:
authStore — the current user, derived from the session. Cached in Zustand so every component can read it synchronously, but the source of truth is the backend session cookie. On app load, useInitAuth asks the backend "who am I?" and writes the result into the store.
dashboardStore — UI preferences: which time range is selected, which stat categories are visible, the order they appear in. This is purely a client concern — the backend doesn't care which categories you've ticked.
// src/stores/dashboardStore.ts (excerpt)
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
timeRange: 'all',
categories: DEFAULT_CATEGORIES,
// ...actions
}),
{ name: 'unwrap-dashboard-preferences' } // localStorage key
)
);The persist middleware writes to localStorage, so dashboard preferences survive refreshes without needing a backend preferences API.
The rule
Server state goes in TanStack Query. Client state goes in Zustand. State that doesn't need to leave a single component goes in
useState.
Do not copy server data into Zustand, that's how you end up with two sources of truth that drift. TanStack Query is the cache.
Dev vs production: the mock API model
Unwrap can run in development without a backend at all. This is intentional and important as the frontend can be developed, tested, and demo'd as a functional app even when the backend is unfinished or offline.
The component, hook, and service code is identical in both modes. The only thing that changes is what answers the fetch call.
How MSW fits in
In dev (and in tests), MSW registers a service worker that intercepts every fetch call and routes it through handlers defined in src/mocks/handlers.ts. Each handler returns realistic mock data from src/mocks/fixtures.ts.
A few consequences:
- No backend required for frontend development. Run
npm run devfrom root of project, and the app behaves as if the backend exists. - The same handlers power tests. Integration tests import the same
serverinstance fromsetup.ts, so test scenarios match the dev experience. - The demo login flow uses MSW. When
USE_MOCKS=true, clicking "Connect with Spotify" doesn't actually call Spotify — it triggers a mock login that drops you into a populated demo account. This is how the demo is experienced when there's no live backend.
How the mode is decided
Three environment variables, all read in src/config/env.ts:
| Variable | Default | Purpose |
|---|---|---|
VITE_USE_MOCKS | true in dev | When true, MSW registers and intercepts. |
VITE_API_URL | empty | Base URL for the real backend, used when MSW is off. |
VITE_PLAYWRIGHT | empty | When true, force MSW off so Playwright can route requests itself. |
Vite env vars are baked at build time
import.meta.env.VITE_* values are inlined into the production bundle when npm run build runs. You can't change them at runtime as every change requires a rebuild. This is a Vite design choice, not an Unwrap one. See src/config/env.ts for how each value is read and what defaults apply.
Request lifecycle: a worked example
To tie all of the above together, here's what happens when a user lands on the dashboard and the Top Tracks card needs to render. Watch which layer does which job.
- The component mounts and calls
useTopTracks('all'). - The hook asks TanStack Query for the data under the key
['top-tracks', 'all']. - First render: the cache is empty, so TanStack Query runs the
queryFn, which callsfetchTopTracks. The service builds a URL and callsfetch. In dev, MSW intercepts; in prod, the backend responds. JSON returns up the chain, gets cached, and renders. - Subsequent renders of any component using
useTopTracks('all')get the cached value returned. - Switch the time range and the key changes to
['top-tracks', '7d']. New cache entry, new fetch, but the old'all'data is still cached and instant if the user switches back.
Caching, loading states, and error handling all happen at the right layer automatically, because each layer only does its own job.
Testing architecture
Tests mirror the runtime architecture. There are three layers of tests, each targeting a different layer of the app.
| Test layer | Tool | Target | Mocks? |
|---|---|---|---|
| Unit | Vitest | Pure functions (utils/, store reducers) | None (no I/O) |
| Integration | Vitest + RTL + MSW | Component + hook + service together | MSW intercepts at the network layer |
| E2E | Playwright | Real browser, real navigation | Backend is mocked or seeded (MSW disabled via VITE_PLAYWRIGHT) |
The integration tests are the most important giving confidence that the three layers actually compose correctly. Because they intercept at fetch (not at the service or hook layer), they exercise every line of real code from the component down to the network boundary.
Trade-offs and intentional omissions
Architectures are defined as much by what they don't include as what they do. Here's what was deliberately left out:
No global event bus or pub/sub. TanStack Query already coordinates server state across components. Zustand stores are subscribed to directly. Adding a third coordination mechanism would be redundant.
No Redux. Two reasons: (1) for server state, TanStack Query is purpose-built and handles caching and refetching natively; (2) for the small amount of client state Unwrap has, Zustand is significantly less ceremony than Redux Toolkit.
No co-located tests. Tests live in src/__tests__/ mirroring the source structure rather than next to the file under test. This keeps the source tree visually clean and makes it obvious at a glance whether a file has a test counterpart.
No GraphQL or tRPC. REST + TanStack Query is enough for the data shapes Unwrap needs. Adding GraphQL would be premature complexity.
No SSR. Unwrap is a pure SPA. The data is per-user and authenticated, so SSR offers little benefit and adds infrastructure burden.
No CSS-in-JS. CSS Modules give scoped styles without runtime cost or bundle bloat.
Responsive shell
Unwrap is mobile-first. The Layout component renders a Navigation component that switches chrome at the 768px breakpoint:
- Below 768px: a fixed bottom tab bar is the standard mobile pattern, with
safe-area-inset-bottomhandling for iOS notches. - At 768px and above: a fixed top navigation with the brand wordmark on the left and the same five links on the right.
Both are rendered from a single navItems array, so adding a new top-level page means one entry in Navigation.tsx and the link appears in both layouts automatically. Page content adjusts via padding-bottom on mobile and padding-top on desktop to clear the fixed nav.
Unmatched routes
Any URL that doesn't match a defined route renders the NotFound component:
<Route path="*" element={<NotFound />} />This sits outside the protected layout, so an unauthenticated user hitting a typo'd URL sees a 404 rather than being redirected through the auth flow.