React Server Components changed the data fetching mental model. The component IS the data fetcher. This post is the working set of patterns from production Next.js 15 apps.
Direct DB access
// app/posts/page.tsx (Server Component)
import { db } from "@/lib/db";
import { posts } from "@/lib/schema";
export default async function PostsPage() {
const list = await db.select().from(posts).orderBy(desc(posts.createdAt)).limit(20);
return <ul>{list.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
No API route. No useEffect. Component runs on the server; query runs on the server; HTML returns. Type-safe end to end via Drizzle
.
Parallel fetching
Two independent queries, both fired:
export default async function Dashboard() {
const [user, recentOrders, recommendations] = await Promise.all([
getUser(),
getRecentOrders(),
getRecommendations(),
]);
return <div>{/* render with all three */}</div>;
}
Three queries fire simultaneously; component renders when all return. Same pattern as asyncio.gather — see AsyncIO Patterns
.
Streaming with Suspense
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations />
</Suspense>
</>
);
}
async function Orders() {
const o = await getOrders(); // slow
return <OrdersList items={o} />;
}
Header renders immediately. Orders and Recommendations stream in independently. The browser sees content as it arrives.
For Next.js 15 Server Components deeper coverage.
Request memoization
React.cache dedupes within a single render:
import { cache } from "react";
import { db } from "@/lib/db";
export const getCurrentUser = cache(async () => {
const sid = (await cookies()).get("sid")?.value;
return db.query.sessions.findFirst({ where: eq(sessions.id, sid) });
});
Call getCurrentUser() from any Server Component; only one DB hit per request. Prevents accidental N+1 from Server Component composition.
Cache control
const data = await fetch(url, { next: { revalidate: 60 } }); // ISR 60s
const data = await fetch(url, { cache: "no-store" }); // dynamic
Or for direct DB calls, opt out of static rendering:
export const dynamic = "force-dynamic"; // always render at request time
Default in Next 15 is more dynamic than 14; check what your route does with next build output.
When to use Server Actions vs fetch
- Read: in a Server Component directly.
- Mutation: Server Action.
async function deletePost(id: number) {
"use server";
await db.delete(posts).where(eq(posts.id, id));
revalidatePath("/posts");
}
<form action={deletePost.bind(null, post.id)}>
<button>Delete</button>
</form>
The button’s action posts to the server. Form-data flows in. After execution, revalidatePath busts the cache.
See React 19 Server Actions .
Common mistakes
1. Calling client-only APIs from Server Components
window, document, localStorage aren’t in Server Components. Use Client Components for those, or pass data as props.
2. Forgetting revalidatePath after mutations
The page caches stale data; user sees old version. Always invalidate after server action mutations.
3. Sequential fetches that should be parallel
const a = await fetchA(); // 200ms
const b = await fetchB(); // 200ms — total 400ms
→ Promise.all for independent calls.
4. Heavy data in client components
A 50k-row dataset shipped to the client = slow page. Keep heavy data on the server; only ship what’s rendered.
5. Suspense without good fallbacks
Empty fallback = layout shift on stream-in. Use skeletons.
Read this next
- Next.js 15 Server Components in Production
- React 19 Server Actions and
use - Drizzle ORM Deep Dive
- Modern TypeScript Backend with Hono on Bun
If you want a Next 15 + Drizzle + Server Actions starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .