Database Access Patterns in Next.js App Router
The App Router changes how you think about data access. Direct database queries in Server Components are now the default pattern — but doing it right requires understanding connection pooling, query optimization, and caching.
Nextcraft Engineering Team
The New Default: Direct DB Access in Server Components
In the Pages Router era, database access happened in getServerSideProps or getStaticProps — isolated functions that ran before rendering. In the App Router, Server Components are async by default, which means you can query your database directly:
// app/dashboard/page.tsx
import { db } from '@/lib/db';
export default async function Dashboard() {
const projects = await db.project.findMany({
where: { ownerId: await getUserId() },
orderBy: { updatedAt: 'desc' },
});
return <ProjectList projects={projects} />;
}
No API layer. No useEffect. No loading states for the initial render. The component renders with data already resolved.
This is powerful, but it introduces concerns that didn't exist when data access was isolated.
Connection Pooling: The Critical Issue
Every Server Component render can open a database connection. In a high-traffic application with hundreds of concurrent requests, this exhausts your database connection limit rapidly.
The solution is a connection pooler. In development, Prisma's built-in connection pool is sufficient. In production (especially serverless environments like Vercel), you need an external pooler:
Prisma Accelerate: Connection pooling as a service from Prisma. Add it by swapping your database URL:
# Instead of direct connection:
DATABASE_URL="postgresql://user:pass@db.example.com:5432/mydb"
# Use Accelerate:
DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=your_key"
PgBouncer: Self-hosted pooler for PostgreSQL. Configure it between your application and database.
Neon / PlanetScale / Supabase: These managed database platforms include connection pooling built in, often with an HTTP-based driver that works better in serverless environments.
// For Neon's serverless driver — HTTP-based, no TCP connection limits
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!);
export default async function Page() {
const users = await sql`SELECT * FROM users WHERE active = true`;
return <UserList users={users} />;
}
The cache() Pattern for Shared Queries
When multiple Server Components on the same page need the same data, use React's cache() to deduplicate database calls:
// lib/queries/user.ts
import { cache } from 'react';
import { db } from '@/lib/db';
// This function is memoized per request — called 10 times, hits DB once
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({
where: { id: session.userId },
include: { subscription: true },
});
});
// app/layout.tsx
import { getCurrentUser } from '@/lib/queries/user';
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser(); // hits DB
return (
<Providers user={user}>
{children}
</Providers>
);
}
// app/dashboard/page.tsx — same request, no second DB call
import { getCurrentUser } from '@/lib/queries/user';
export default async function Dashboard() {
const user = await getCurrentUser(); // returns memoized result
return <DashboardContent user={user} />;
}
Parallel Data Fetching
Avoid sequential waterfalls. Fetch independent data in parallel:
// Bad — sequential: each awaits the previous
export default async function ProjectPage({ params }: Props) {
const project = await getProject(params.id); // 100ms
const members = await getProjectMembers(params.id); // +100ms
const activity = await getProjectActivity(params.id); // +100ms
// Total: ~300ms
}
// Good — parallel with Promise.all
export default async function ProjectPage({ params }: Props) {
const [project, members, activity] = await Promise.all([
getProject(params.id),
getProjectMembers(params.id),
getProjectActivity(params.id),
]);
// Total: ~100ms (longest query)
}
For queries that might fail independently, use Promise.allSettled:
const [projectResult, activityResult] = await Promise.allSettled([
getProject(params.id),
getProjectActivity(params.id),
]);
const project = projectResult.status === 'fulfilled' ? projectResult.value : null;
const activity = activityResult.status === 'fulfilled' ? activityResult.value : [];
Optimistic Updates with Server Actions
Server Actions combined with useOptimistic give you the UX of optimistic updates with server-authoritative state:
'use client';
import { useOptimistic } from 'react';
import { toggleProjectStatus } from '@/lib/actions';
export function ProjectItem({ project }: { project: Project }) {
const [optimisticProject, updateOptimistic] = useOptimistic(
project,
(current, newStatus: 'active' | 'archived') => ({
...current,
status: newStatus,
})
);
async function handleToggle() {
const newStatus = optimisticProject.status === 'active' ? 'archived' : 'active';
updateOptimistic(newStatus); // Immediate UI update
await toggleProjectStatus(project.id, newStatus); // Server sync
}
return (
<div>
<span>{optimisticProject.status}</span>
<button onClick={handleToggle}>Toggle</button>
</div>
);
}
The UI responds instantly. If the server action fails, React rolls back the optimistic state automatically.
Query Organization
As your application grows, centralize queries in a lib/queries/ directory:
lib/
queries/
user.ts — getCurrentUser, getUserById, getUsersByOrg
project.ts — getProject, getUserProjects, getProjectWithMembers
analytics.ts — getProjectMetrics, getDailyActiveUsers
actions/
project.ts — createProject, updateProject, deleteProject
user.ts — updateProfile, changeEmail
db.ts — Prisma client singleton
This keeps data access logic out of components, makes queries reusable across pages, and gives you a clear place to add caching, logging, and validation.
The Prisma Singleton
Never instantiate Prisma Client directly in modules — it creates a new connection on every hot-reload in development:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
This pattern ensures one client instance across hot-reloads in development, while creating a fresh instance in production where hot-reload doesn't apply.
Continue reading
Related articles
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.