Skip to main content
paulund

3 min read

#nextjs#prisma#architecture#typescript

Extracting a Queries Layer from Next.js Server Components

In a Next.js App Router project using Prisma, the natural place for a database query is right inside the page component. The page is async, the data is needed for rendering, so you call prisma directly:

export default async function DashboardPage() {
    const user = await getUser()

    const [projects, providers] = await Promise.all([
        prisma.project.findMany({
            where: { user_id: user.id },
            include: {
                _count: { select: { posts: true, accounts: true } },
            },
            orderBy: { created_at: 'asc' },
        }),
        prisma.aiProvider.findMany({
            where: { user_id: user.id },
            take: 1,
        }),
    ])

    // ... render
}

This works fine for a handful of pages. As the app grows, the same filter shapes repeat across files, complex include blocks end up buried inside components, and there's no single place to look when you want to understand what data a model exposes.

A queries layer solves this with a simple rule: all Prisma reads live in src/lib/queries/, one file per model. Pages call query functions. Mutations stay in src/lib/actions/. No page imports prisma directly.

What the queries layer looks like

Each file in src/lib/queries/ exports typed functions wrapping Prisma reads. Here's an example project.ts:

export async function getProjectBySlug(slug: string, userId: string) {
    return prisma.project.findFirst({
        where: { slug, user_id: userId },
    })
}

export async function getProjectWithGoals(slug: string, userId: string) {
    return prisma.project.findFirst({
        where: { slug, user_id: userId },
        include: {
            goals: {
                include: { account: true },
                orderBy: { created_at: 'desc' },
            },
            accounts: { orderBy: { created_at: 'asc' } },
        },
    })
}

The page becomes:

import { getProjectWithGoals } from '@/lib/queries/project'

export default async function GoalsPage({ params }) {
    const { projectSlug } = await params
    const user = await getUser()

    const project = await getProjectWithGoals(projectSlug, user.id)
    if (!project) notFound()
    // ...
}

The page is about rendering. The query function is about data shape. They don't overlap.

The rule

Reads go in lib/queries/. Writes go in lib/actions/. No page imports @/lib/db directly.

You can enforce this mechanically. Add a CI check that fails if any file inside app/ imports directly from your database client. If a page needs data, it calls a query function. If it needs to mutate, it calls a server action.

Pagination done once

Paginated reads are where inline queries get messy fast. Before extracting:

const skip = (page - 1) * PAGE_SIZE

const [ideas, total] = await Promise.all([
    prisma.idea.findMany({
        where: { project_id: projectId, has_been_drafted: false },
        orderBy: { created_at: 'desc' },
        take: PAGE_SIZE,
        skip,
    }),
    prisma.idea.count({
        where: { project_id: projectId, has_been_drafted: false },
    }),
])

After extracting to lib/queries/idea.ts:

export async function getIdeasPage({
    projectId,
    page,
    pageSize,
}: {
    projectId: string
    page: number
    pageSize: number
}) {
    const skip = (page - 1) * pageSize
    const where = { project_id: projectId, has_been_drafted: false }

    const [items, total] = await Promise.all([
        prisma.idea.findMany({ where, orderBy: { created_at: 'desc' }, take: pageSize, skip }),
        prisma.idea.count({ where }),
    ])

    return { items, total }
}

The page calls it as:

const { items: ideas, total } = await getIdeasPage({
    projectId: project.id,
    page,
    pageSize: PAGE_SIZE,
})

The pagination arithmetic lives once and returns a consistent { items, total } shape across every paginated list in the app.

Misplaced reads

During a migration like this you often find reads that ended up in the wrong place. A common one: a function in lib/actions/ that does nothing but read data. It got there because it was originally called from a client component, not because it was a mutation.

If it only reads, it belongs in lib/queries/. The naming becomes honest and the file is where anyone would look for it.

Why bother

Discovery. If you want to understand what queries exist for a given model, open its file in lib/queries/. You don't have to grep across every page component.

Reuse. A simple getProjectBySlug might be called across a dozen different pages. Before the refactor, each has its own prisma.project.findFirst with the same where clause. Now there's one place to change it.

Testability. Query functions are plain async functions with typed arguments. They're easy to test against a real database or a mock without rendering a component at all.

Slimmer pages. Each page that previously built its own Prisma query inline loses 10–20 lines. The component itself is shorter and easier to read at a glance.

What this is not

This is not a repository pattern in the DDD sense. There's no interface, no abstraction layer over the ORM. The query functions call Prisma directly and return Prisma types. The goal is to move data access out of page files into a consistent location, not to make Prisma swappable.

If the app ever reaches a point where abstracting the data layer makes sense, the queries directory is the right place to introduce that abstraction. Until then, it's a file organisation convention with a clear naming rule.

Conclusion

If you're building a Next.js App Router app with Prisma and you find yourself reaching for prisma.* inside page components, a queries layer is worth adding. The migration is mechanical, the rule is simple to enforce, and the result is a codebase where data access has one clear home.

Related notes


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.