Tutorial: Build a Rendering and Data Architecture Lab
Why this tutorial matters
Rendering architecture is difficult to learn from definitions alone. SSR, SSG, ISR, RSC, client rendering, streaming, route loaders, caches, mutations, optimistic UI, and offline states only make sense when you build one app that uses them deliberately.
This tutorial builds a mid-level Commerce Operations Portal. The app has a public catalog, authenticated dashboard, order detail pages, editable customer notes, search filters, and a small offline-capable queue for follow-up tasks. The goal is not to build a beautiful commerce product. The goal is to practice rendering and data decisions until you can explain why each route uses a different strategy.
What you will build
| Surface | Rendering/data focus |
|---|---|
| Public catalog | SSG or ISR, cacheable product data, static shell |
| Product detail | ISR with revalidation and stale fallback |
| Authenticated dashboard | Server-rendered route shell plus client data refresh |
| Order detail | Server-loaded critical data, client islands for comments/actions |
| Search and filters | URL search params, schema validation, cache keys |
| Follow-up tasks | Mutations, optimistic state, retry, offline queue |
Architecture target
Routes
/catalog static or ISR
/catalog/:productId ISR with revalidation
/app/dashboard authenticated server route
/app/orders/:orderId server data + client islands
/app/search URL state + route loader
Data layer
route loaders for first-view data
query cache for client refresh
mutation client for writes
server actions/API routes for trusted writes
shared cache key conventions
React Server Components can render ahead of time or per request depending on framework support. The important lesson is the boundary: server-only data and heavy dependencies should stay off the client, while interactive state stays in client islands.
Milestone 1: Route inventory and rendering matrix
Before coding, write a route matrix.
| Route | User type | Freshness | Interactivity | Strategy |
|---|---|---|---|---|
/catalog | anonymous | daily/hourly | low | SSG/ISR |
/catalog/:id | anonymous | hourly | medium | ISR plus client island for availability |
/app/dashboard | authenticated | minutes | medium | SSR/server loader |
/app/orders/:id | authenticated | live-ish | high | server critical data plus client islands |
/app/search | authenticated | query-dependent | medium | loader keyed by search params |
Acceptance criteria:
- Every route has a strategy and reason.
- No route is marked "SSR because dynamic" without naming which data is dynamic.
- Search params are part of the architecture, not local component state.
Milestone 2: Build route loaders and cache keys
Use a consistent key structure:
const keys = {
catalog: () => ["catalog"],
product: (id: string) => ["product", id],
dashboard: (userId: string) => ["dashboard", userId],
order: (orderId: string) => ["order", orderId],
search: (params: SearchParams) => ["search", normalizeSearch(params)],
};
Rules:
- cache keys include every input that changes output
- keys do not include secrets or unstable object identity
- server and client use compatible key naming
- revalidation policy is documented per key
Acceptance criteria:
- Search URL can be copied and reopened.
- Cache invalidation after mutation updates the right route/query.
- Duplicate data fetches are visible in logs and removed.
Milestone 3: Server/client component boundary
Create a route where most content is server-rendered but comments/actions are client islands.
OrderDetailPage.server
-> OrderSummary.server
-> FulfillmentTimeline.server
-> CustomerNotes.client
-> FollowUpTaskForm.client
Boundary rules:
- server components load critical first-view data
- client components own local input, focus, optimistic state, and browser APIs
- server-only code never imports into client modules
- client islands receive serializable props only
- mutations return structured results, not arbitrary thrown strings
Acceptance criteria:
- Bundle analysis proves server-only libraries are not in the client bundle.
- Client islands have loading, pending, success, and error states.
- Hydration does not shift layout.
Milestone 4: Streaming and suspense states
Split slow secondary data from critical data.
| Data | Strategy |
|---|---|
| Order header and status | block initial render |
| Timeline | stream if slow |
| Related recommendations | defer |
| Audit log | lazy tab |
Design skeletons with stable dimensions. Avoid a spinner-only page. The user should see the route identity and critical state quickly, even if secondary panels stream later.
Acceptance criteria:
- First useful view appears before secondary panels.
- Suspense fallback does not cause layout shift.
- Errors in secondary panels do not crash the route.
Milestone 5: Mutations and consistency
Implement note editing and follow-up task creation.
Mutation contract:
type MutationResult<T> =
| { ok: true; data: T; version: number }
| { ok: false; code: "validation" | "conflict" | "auth" | "network"; message: string };
Consistency rules:
- optimistic update only for reversible changes
- server response reconciles final state
- conflicts show a resolution UI
- retries use idempotency keys for create actions
- failed writes preserve user input
Acceptance criteria:
- A failed mutation does not lose typed text.
- Duplicate submit does not create duplicate tasks.
- Conflict state is testable.
Milestone 6: Offline-lite queue
Add a minimal queue for follow-up tasks:
type QueuedMutation = {
id: string;
type: "create_follow_up";
payload: unknown;
idempotencyKey: string;
createdAt: string;
status: "queued" | "syncing" | "failed";
};
Do not pretend the whole app is offline-first. This is an offline-lite exercise: preserve user intent for one safe mutation type.
Acceptance criteria:
- User can create a follow-up while temporarily offline.
- Queue retries with backoff.
- User can cancel a queued mutation.
- Server idempotency prevents duplicates.
Milestone 7: Verification
Run these checks:
| Check | Purpose |
|---|---|
| route matrix review | confirms each route has strategy |
| duplicate request log | catches repeated loaders/fetches |
| bundle analysis | catches server code in client bundle |
| mutation failure tests | verifies state preservation |
| search param tests | verifies URL state and cache keys |
| slow network test | verifies streaming and fallbacks |
Implementation sequence
Build the lab in this order:
- Create route files and empty layouts.
- Add static catalog data and SSG/ISR route.
- Add product detail route with revalidation.
- Add authenticated dashboard shell with mocked session.
- Add route loaders and cache keys.
- Add search params with schema validation.
- Add order detail server data.
- Add notes and follow-up client islands.
- Add mutation failure and conflict scenarios.
- Add offline-lite queue for one safe mutation.
Do not add caching libraries before the data ownership is clear. A cache cannot fix a confused contract.
Data contract worksheet
For each data source, fill this out:
| Field | Required answer |
|---|---|
| Owner | Which backend/domain owns the data? |
| Freshness | How stale can it be? |
| Visibility | Anonymous, authenticated, role-specific, tenant-specific? |
| First view | Does the route need this before useful render? |
| Mutation | Can the user change it? |
| Invalidates | Which keys/routes become stale after change? |
| Failure UI | What happens if it cannot load? |
Example:
| Field | Answer |
|---|---|
| Owner | Orders service |
| Freshness | Status should be under 30s stale |
| Visibility | Authenticated, tenant-scoped |
| First view | Header/status yes, audit log no |
| Mutation | Notes and follow-up tasks only |
| Invalidates | order(orderId), dashboard(userId) |
| Failure UI | Header error blocks route; audit log error is local |
Mid-level extensions
Add these after the core lab works:
- prefetch product detail route on catalog hover
- add stale-while-revalidate indicator for dashboard cards
- add optimistic note update with conflict detection
- add route-level pending navigation state
- add background refresh that pauses when tab is hidden
- add pagination to search with URL state
- add role-based loader behavior
- add server-side redirect for unauthorized order access
Each extension should include one failure state. The tutorial is not complete if every feature only works on the happy path.
Capstone review
Answer:
- Which routes are static, server-rendered, streamed, or client-heavy?
- Which data is critical for first view?
- Which data can be stale?
- What invalidates each cache key?
- Which mutations are optimistic?
- Which writes require idempotency?
- What breaks if the network drops after user input?
Source lens
This tutorial is informed by React Server Components guidance, TanStack Router concepts around route loaders/search params/type safety, and the book's rendering, caching, mutation consistency, and offline-first chapters.