Chapter 5: RSC Data Fetching, Caching, and Streaming UI
In Chapter 4, we established RSC's 'architecture' - tree-based routing and hash table-based caching. In this chapter, we'll dive deep into the 'runtime' of these architectures: how we specifically fetch, refresh, and 'stream' data to build a high-performance SaaS dashboard.
Chapter 5: RSC Data Fetching, Caching, and Streaming UI
In Chapter 4, we established RSC's "architecture" - tree-based routing and hash table-based caching. In this chapter, we'll dive deep into the "runtime" of these architectures: how we specifically fetch, refresh, and "stream" data to build a high-performance SaaS dashboard.
5.1. Direct Data Access in RSC (Goodbye APIs)
This is the first "liberating" mindset shift when moving from Python backend (like Flask/FastAPI) to RSC.
-
Your Past (Flask/FastAPI): You needed to create two endpoints:
- A
GET /api/posts/1endpoint (API) that queries the database and returns JSON. - A
GET /posts/1endpoint (Page) thatfetches your own/api/posts/1, then renders in a template.
- Problem: Your server is talking to itself. This adds unnecessary network overhead, serialization/deserialization costs, and code fragmentation.
- A
-
Your Present (RSC): RSCs run on the server by default. You don't need API endpoints. You can directly and safely
importyour server-side modules (likesrc/db/andsrc/payment/) and execute them. -
Algorithmic Thinking (1.4): Graph
- This pattern greatly simplifies your application's "call graph". You cut out the entire
/api/...branch and all thefetchedges it brings. - Your
Page.tsxnode can now directly "connect" to yourdb.tsnode. This "logic colocation" is one of the sources of RSC's performance advantage.
- This pattern greatly simplifies your application's "call graph". You cut out the entire
-
SaaS Real Code (
page.tsx):// app/dashboard/page.tsx // This is an RSC, it runs on the server by default import { auth } from '@/lib/auth'; // 1. Directly import Better Auth (server module) import { db } from '@/db'; // 2. Directly import Drizzle (server module) import { eq } from 'drizzle-orm'; import { users, posts } from '@/db/schema'; export default async function DashboardPage() { // 3. Directly 'await' authentication const session = await auth(); const userId = session?.user?.id; if (!userId) { // ... handle not logged in } // 4. Directly 'await' database queries // We don't need /api/get-posts-for-user const [user, userPosts] = await Promise.all([ db.query.users.findFirst({ where: eq(users.id, userId), columns: { name: true, credits: true } // Only select needed fields }), db.query.posts.findMany({ where: eq(posts.authorId, userId), limit: 5, }) ]); // 5. Directly render. No JSON serialization, no API fetch return ( <div> <h1>Welcome, {user?.name}</h1> <p>You have {user?.credits} credits remaining.</p> <h2>Your Latest Posts:</h2> <ul> {userPosts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
5.2. [Skill Practice]: Elegantly Getting IDs from pathname and Fetching Data in RSC
-
Problem: If the page is dynamic, like
.../posts/xyz-123, how do we get thexyz-123ID? -
Architecture Mapping: As mentioned in section 4.1, routing is a "tree". When you create a folder named
src/app/dashboard/posts/[postId]/:[postId]is a "dynamic wildcard node".- Next.js "captures" the value of this URL segment during route matching.
- This "captured value" is automatically injected as
propsinto yourlayout.tsxandpage.tsx.
-
SaaS Real Code (
.../posts/[postId]/page.tsx):// app/dashboard/posts/[postId]/page.tsx import { db } from '@/db'; import { eq } from 'drizzle-orm'; import { posts } from '@/db/schema'; import { notFound } from 'next/navigation'; // 404 helper function from Next.js // 1. Next.js automatically injects URL segment into 'params' prop // Visiting /posts/xyz-123 results in params = { postId: 'xyz-123' } interface PostPageProps { params: { postId: string; // This key name 'postId' must match folder name '[postId]' }; } export default async function PostPage({ params }: PostPageProps) { const { postId } = params; // 2. [Algorithm: O(log n) or O(1)] // Use 'postId' for efficient database lookup const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), with: { author: { // Drizzle relational query columns: { name: true } } } }); // 3. Handle not found if (!post) { notFound(); } return ( <article> <h1>{post.title}</h1> <p>By {post.author.name}</p> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); }
5.3. Next.js Data Caching and On-Demand Revalidation (revalidatePath)
-
Algorithmic Thinking (1.4): Hash Table and Cache Invalidation
-
Architecture Mapping:
- Caching (Hash Table): As mentioned in section 4.3, Next.js's
fetchand React'scacheautomatically store data in a hash table (Next.js Data Cache), where the key isurlor function name+parameters, and the value is the data. - Problem: What if the data in the database changes? Our hash table (cache) is now "stale".
- Cache Invalidation:
revalidatePathis a command that tells Next.js: "Please delete all entries related to this path/dashboard/settingsfrom the hash table." - When the next request visits
/dashboard/settings, Next.js doesn't find data in the hash table, so it will re-execute yourawait db.query..., fetch new data, and store it in the hash table again.
- Caching (Hash Table): As mentioned in section 4.3, Next.js's
-
SaaS Practice (Using in Server Action): When users update their profile in
src/actions/, we must "refresh" the cache.// src/actions/user-actions.ts 'use server'; // This is a Server Action import { revalidatePath } from 'next/cache'; import { db } from '@/db'; import { auth } from '@/lib/auth'; import { users } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; const UpdateNameSchema = z.string().min(2); export async function updateUserName(newName: string) { const session = await auth(); if (!session?.user?.id) return { error: 'Not authenticated' }; // 1. [Chapter 2] Zod runtime validation const validation = UpdateNameSchema.safeParse(newName); if (!validation.success) return { error: 'Invalid name' }; try { // 2. Update database await db.update(users) .set({ name: validation.data }) .where(eq(users.id, session.user.id)); // 3. [Algorithm: Cache invalidation] // This is key! We "destroy" the cache for /dashboard page. // Next time user visits /dashboard, // the DashboardPage RSC from section 5.1 will re-run, // fetching the new 'user.name'. revalidatePath('/dashboard'); revalidatePath('/dashboard/settings'); // You can revalidate multiple paths return { success: true, message: 'Name updated!' }; } catch (e) { return { error: 'Database error' }; } }
5.4. [Skill Practice]: Building a Streaming Loading Dashboard with Suspense and searchParams
-
Problem: Our
DashboardPage(from 5.1) has twoawaits. If theuserPostsquery is slow (e.g., 3 seconds), the entire page is blocked for 3 seconds, even though the user'snamereturns in 50 milliseconds. -
Algorithmic Thinking (1.4): Queue / Concurrency / Streaming
-
Architecture Mapping:
<Suspense>allows you to "decouple" the page. It tells React: "Don't block the entire page render. Send myfallback(placeholder, like a skeleton) to the user first - this is like a "claim ticket" in a queue."- "Meanwhile, continue executing this slow
awaittask on the server." - "When the task completes, stream the rendered HTML result as a new data chunk to the client, replacing that 'claim ticket'."
-
searchParams(URL Query Parameters):**page.tsxalso automatically receivessearchParams(like?q=...) asprops. We can use it to drive a searchable, streaming list. -
SaaS Practice (Building Streaming Dashboard):
// **1. Page (`app/dashboard/page.tsx`)** import { Suspense } from 'react'; import { auth } from '@/lib/auth'; import { UserWelcome } from '@/components/user-welcome'; // Fast component import { ProjectList, ProjectListSkeleton } from '@/components/project-list'; // Slow component import { SearchBar } from '@/components/search-bar'; // Client component // 'searchParams' is also automatically injected export default async function DashboardPage({ searchParams, }: { searchParams: { q?: string }; }) { const query = searchParams.q || ''; const session = await auth(); // Assume auth() is fast return ( <div> {/* 1. Fast component: renders immediately */} <UserWelcome userId={session?.user?.id} /> {/* 2. Client component: for updating URL (see below) */} <SearchBar /> {/* 3. Slow component: wrapped in Suspense */} {/* fallback is a placeholder that displays immediately */} <Suspense key={query} fallback={<ProjectListSkeleton />}> {/* 4. 'ProjectList' is an async RSC that executes slow query */} {/* It receives 'query' from parent RSC */} <ProjectList query={query} /> </Suspense> </div> ); }2. Slow Component (
components/project-list.tsx)import { db } from '@/db'; import { projects } from '@/db/schema'; import { like } from 'drizzle-orm'; export async function ProjectList({ query }: { query: string }) { // 5. Simulate a very slow database query await new Promise(resolve => setTimeout(resolve, 2000)); const userProjects = await db.query.projects.findMany({ where: like(projects.name, `%${query}%`), // ... }); // 6. After 2 seconds, this HTML is streamed to the client return ( <ul> {userProjects.map(p => <li key={p.id}>{p.name}</li>)} {userProjects.length === 0 && <li>No projects found.</li>} </ul> ); } export function ProjectListSkeleton() { // This is a lightweight placeholder return <div>Loading projects...</div>; }3. Search Bar (Client Component) (
components/search-bar.tsx)'use client'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; export function SearchBar() { const searchParams = useSearchParams(); const pathname = usePathname(); const { replace } = useRouter(); // router from 'next/navigation' function handleSearch(term: string) { const params = new URLSearchParams(searchParams); if (term) { params.set('q', term); } else { params.delete('q'); } // 7. [Key]: We don't 'fetch' data here. // We only change the URL. This URL change triggers Next.js to automatically // re-render 'DashboardPage' on the server, // passing in new 'searchParams', and re-triggering the 'Suspense' boundary. replace(`${pathname}?${params.toString()}`); } return ( <input onChange={(e) => handleSearch(e.target.value)} defaultValue={searchParams.get('q') || ''} /> ); }Result: The page loads immediately, showing
UserWelcomeandProjectListSkeleton. After 2 seconds,ProjectListHTML "streams" in, replacing the skeleton. When users type inSearchBar, the URL changes, theSuspenseboundary re-triggers, showing the skeleton again, then streaming new search results.
Categories
More Posts
Chapter 11: Payments and Subscriptions (Stripe)
Welcome to Part Five, the core business logic of our SAAS application. In previous chapters, we built a solid application foundation—from frontend UI, RSC data flow, secure Server Actions to type-safe Drizzle ORM and 'better-auth' authentication. Now, it's time to transform our application from a 'project' into a 'product': implementing paid subscriptions.
Chapter 10: Database Migrations and Operations
In Chapters 7 and 9, we defined our database table structure in the src/db/schema/ directory. But we left a critical question: when you modify the schema (like adding a bio field to usersTable), how do you safely apply this change to a production database that's already running live?
Chapter 16: SAAS Testing Strategy and Quality Assurance
In Chapter 14, we established a CI/CD pipeline that acts as the 'gatekeeper' for our SAAS application. In Chapter 15, we used Biome (Linter) to ensure 'static quality' of code. However, a gatekeeper that only checks syntax is far from enough. Our CI process also includes a pnpm test command—this is the core of quality assurance.