Home / Articles / Stop Leaking Database Fields: A Data Access Layer for Next.js Apps

This article is published in English.

Stop Leaking Database Fields: A Data Access Layer for Next.js Apps

Learn how a Data Access Layer centralizes auth checks and field filtering in Next.js Server Components, so sensitive columns never reach the browser by accident.

1597 words

Server Components let you query the database straight from a component. The catch: whatever object you hand to a Client Component is serialized and sent to the browser, including fields you never meant to show. A Data Access Layer (DAL) sits between components and the database so authentication, authorization and field filtering happen in one place. Here is how to structure one, what belongs in it, and when it is worth the extra files.

What a Data Access Layer is

A DAL is a dedicated folder that holds every database query. Components never call Prisma directly; they call functions such as getProfile, which decide what the caller may see and which fields come back. Think of it as a checkpoint on the way to the UI: it verifies who is asking and strips out everything the screen does not need. The Next.js data security docs recommend this approach for new projects.

A typical layout keeps authentication helpers next to the query modules for each domain:

src/
  data/
    auth.ts     # Authentication helpers
    user.ts     # User queries
    posts.ts    # Post queries

The rule that makes this work is simple: nothing outside data/ imports the database client.

How raw queries leak data

A direct query returns the entire row. In the snippet below, the component fetches a user and passes the result straight to a card:

// Without DAL - directly in a component
const user = await prisma.user.findUnique({ where: { id } })
return <ProfileCard user={user} />

That object holds every column: password hash, email, phone number, internal IDs. If ProfileCard is a Client Component, the whole object is serialized into the page payload, and anyone with the network tab open can read it, even if the card renders only the name.

The fix is to return a purpose-built object instead of the raw record. This DAL function selects the three fields the profile view actually uses:

// data/user.ts
export async function getProfile(id: string) {
  const user = await prisma.user.findUnique({ where: { id } })
  return {
    name: user.name,
    avatar: user.avatar,
    bio: user.bio
  }
}

The component calls getProfile(id) and gets a safe object; the password hash stays on the server. You could also filter in the query with Prisma's select, and you should handle findUnique returning null, since user.name would throw.

Why per-component security breaks down

The bigger risk in a growing codebase is that each component implements its own access rules, and they drift apart. Picture two developers on one app. The first builds the profile page on a DAL function meant to carry authentication checks (the snippet repeats the filtered function above and does not contain a check yet; the protected version follows in the next section):

// data/user.ts
export async function getProfile(id: string) {
  const user = await prisma.user.findUnique({ where: { id } })
  return {
    name: user.name,
    avatar: user.avatar,
    bio: user.bio
  }
}

The second developer builds a settings page and queries Prisma directly, reading the user id from the URL and never checking who is signed in:

// pages/settings/page.tsx - Developer B wrote this
export default async function SettingsPage({ searchParams }) {
  // No auth check!
  const user = await prisma.user.findUnique({
    where: { id: searchParams.id }
  })

  // Returns everything, including sensitive fields
  return <Settings user={user} />
}

Now there are two bugs: no authentication, so anyone who edits the id parameter can load another person's settings, and the full record, sensitive fields included, goes to the UI. (Despite the pages/ comment, this is an App Router page under app/.) With security logic spread across pages, one forgotten check is enough, and an audit must inspect every component that touches the database.

Putting authentication and filtering in one place

With a DAL, every caller goes through the same checks. getCurrentUser reads the session and returns the user or null; requireAuth redirects to login when nobody is signed in. Both are wrapped in React's cache, so repeated calls within one request reuse the first result:

// data/auth.ts
import { cache } from 'react'

export const getCurrentUser = cache(async () => {
  const session = await getSession()
  if (!session) return null
  return session.user
})

export const requireAuth = cache(async () => {
  const user = await getCurrentUser()
  if (!user) redirect('/login')
  return user
})

The imports are omitted: getSession comes from your auth library and redirect from next/navigation.

getProfile now requires a signed-in viewer and exposes the email only to the profile's owner:

// data/user.ts
import 'server-only'
import { requireAuth } from './auth'

export async function getProfile(id: string) {
  const viewer = await requireAuth()
  const user = await prisma.user.findUnique({ where: { id } })

  return {
    name: user.name,
    avatar: user.avatar,
    email: viewer.id === user.id ? user.email : null
  }
}

Every caller gets authentication and filtering for free; the second developer cannot skip a check built into the function. Note that requireAuth only answers "is someone signed in?". Whether this viewer may edit this post is authorization, and it belongs in the DAL too. For a deeper treatment of that split, see where authentication and authorization each belong.

Pages become thin. They fetch through the DAL and render:

// Any component - simple and secure
export default async function ProfilePage({ params }) {
  const profile = await getProfile(params.id)
  return <Profile profile={profile} />
}

The component holds no security code, and an audit means reviewing data/ rather than hundreds of components. In recent Next.js versions params is a Promise, so await it first.

Fetching in parallel from DAL functions

Dashboards often run independent queries one after another:

// Sequential fetching - slow
export default async function Dashboard() {
  const user = await prisma.user.findUnique({ where: { id } })
  const posts = await prisma.post.findMany({ where: { authorId: id } })
  const stats = await prisma.stats.findFirst({ where: { userId: id } })

  return <DashboardUI user={user} posts={posts} stats={stats} />
}

Each await waits for the previous one, so three 100 ms queries cost about 300 ms. Since they are independent, this DAL function starts them together with Promise.all and maps the results to the needed fields:

// data/dashboard.ts
export async function getDashboardData(userId: string) {
  const [user, posts, stats] = await Promise.all([
    prisma.user.findUnique({ where: { id: userId } }),
    prisma.post.findMany({ where: { authorId: userId } }),
    prisma.stats.findFirst({ where: { userId } })
  ])

  return {
    user: { name: user.name, avatar: user.avatar },
    posts: posts.map(p => ({ id: p.id, title: p.title })),
    stats: { views: stats.views, followers: stats.followers }
  }
}

Total time drops to that of the slowest query, about 100 ms. The gain comes from Promise.all rather than the DAL itself, but a data function makes the pattern consistent and keeps filtering beside fetching. Dependent queries still have to wait. Likewise, because requireAuth uses cache(), many components can call authenticated DAL functions while the session is read once per request.

Guarding the layer with server-only

Start every file in the DAL with this import:

import 'server-only'

The server-only package fails the build when a module importing it lands in client code, so a DAL function imported into a Client Component by mistake produces a clear error before production. The convention becomes something tooling enforces.

What belongs inside the DAL

Keep each DAL function focused on four jobs:

  • Authentication: confirm there is a signed-in user, using the cached helpers so the check runs once per request.
  • Authorization: confirm this user may access this particular record, for example whether they can view a profile or edit a post.
  • Filtering: hand back just the fields a screen renders. If you are unsure whether a field is needed, leave it out.
  • Secrets: only the DAL should read sensitive configuration such as DATABASE_URL, which keeps connection details out of component code.

Server Actions should use the same functions, so writes get the same checks as reads; see authorizing inside every Server Action.

When a DAL is worth it

Use one for anything handling user data, authentication or sensitive information; in a production app with accounts it is the baseline, not an extra. Skip it only for throwaway prototypes or static sites without user data, and even there, returning explicit objects is a cheap habit worth keeping.

Key takeaways

  • Whatever reaches a Client Component reaches the browser, so never pass raw database rows across that boundary.
  • Route every query through a data/ folder that handles authentication, authorization and field selection.
  • Wrap session lookups in React's cache so many DAL calls cost one session read per request.
  • Use Promise.all inside DAL functions for independent queries.
  • Import server-only in every DAL file so a misplaced import fails the build instead of the security review.

Server Components make database access easy; a Data Access Layer makes sure that ease does not quietly turn into a data leak.