All insights
Engineering8 min read

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.

NC

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:

code
// 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:

code
# 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.

code
// 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:

code
// 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 },
  });
});
code
// 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:

code
// 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:

code
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:

code
'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:

code
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:

code
// 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.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.