query-service
Services: Server-side Prisma Queries for Server Components
You implement server-only service functions that run Prisma SELECT and COUNT queries for Next.js Server Components (RSC).
These services live in @src/services/, are domain-scoped, have easy-to-understand names, and are re-exported via @src/services/index.ts. They use the Prisma type patterns defined in the prisma types skill(Prisma.validator + GetPayload) for return types.
When to use this skill
Use this skill when the user asks to:
- add/refactor server-side Prisma reads used in Server Components
- implement list pages with pagination, filtering, sorting, and total counts
- organize server data access in a
services/folder - ensure return types match the project’s
types/<domain>/...patterns
Folder & naming conventions
- Services live here:
@src/services/<domain>/... - File names are domain-specific and descriptive:
@src/services/users/get-users.ts@src/services/events/get-event-previews.ts@src/services/workspaces/get-workspace-members.ts
- Exported in:
@src/services/index.ts - Each service function name is a clear verb phrase:
getUsers,getWorkspaceMembers,getEventPreviews,countUsers
Hard rules
- Server-only: add
"use server"at the top of each service file. - Read-only responsibilities: these services are for SELECT/COUNT for server rendering.
- Mutations belong in tRPC controllers (unless the user explicitly wants a server action for a mutation).
- Type-safe outputs: the returned model shapes must come from the Prisma type-settings skill:
- Define reusable
select/includeobjects intypes/<domain>/... - Derive payload types via
Prisma.<Model>GetPayload<...>
- Define reusable
- Parallelize data + count: for paginated lists, fetch
findManyandcountinPromise.all. - Avoid overfetching: always use
select(preferred) or strictinclude. - Deterministic ordering: if sorting by a non-unique field (e.g.
createdAt), add a tie-breaker order byidto keep pagination stable. - Validate/whitelist sorting keys: never pass arbitrary
sortBydirectly intoorderBywithout a whitelist. - Performance-aware counts: use
db.<model>.count({ where })for simple counts; if count becomes complex and slow, consider raw SQL read optimization per Prisma querying skill (but keep this as an exception and keep it parameterized).
Service function structure
Each service file should typically contain:
"use server"dbimport from~/server/db- An
Optionsinterface (only if needed) wherebuilder (search/filter)orderBybuilder with a whitelistPromise.all([findMany, count])- Return a typed result object
Recommended return shape for list endpoints
{
items: T[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
Use domain-appropriate names (users, events) if that improves clarity, but keep the structure consistent.
Typing: reference the Prisma type-settings skill
Do not hand-write response shapes for Prisma models. Instead:
1) Define a reusable select in @src/types/<domain>/...
Example:
src/types/users/user.select.tssrc/types/users/user.types.ts
// src/types/users/user.select.ts
import { Prisma } from "@prisma/client";
export const userListSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
name: true,
email: true,
emailVerified: true,
isAdmin: true,
createdAt: true,
});
You can read more about this in the types skill
// src/types/users/user.types.ts
import { Prisma } from "@prisma/client";
import { userListSelect } from "./user.select";
export type UserListItem = Prisma.UserGetPayload<{
select: typeof userListSelect;
}>;
2) Use those exports in the service and type the return
// src/services/users/get-users.ts
"use server";
import { db } from "~/server/db";
import { userListSelect } from "~/types/users/user.select";
import type { UserListItem } from "~/types/users/user.types";
interface GetUsersOptions {
page?: number;
pageSize?: number;
search?: string;
sortBy?: "createdAt" | "name" | "email"; // whitelist
sortOrder?: "asc" | "desc";
}
type GetUsersResult = {
users: UserListItem[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
};
export async function getUsers(options: GetUsersOptions = {}): Promise<GetUsersResult> {
const {
page = 1,
pageSize = 10,
search = "",
sortBy = "createdAt",
sortOrder = "desc",
} = options;
const safePage = Math.max(1, page);
const safePageSize = Math.min(Math.max(1, pageSize), 200);
const skip = (safePage - 1) * safePageSize;
const where = search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {};
// Stable ordering: requested field + id tie-breaker
const orderBy = [{ [sortBy]: sortOrder } as const, { id: "asc" as const }];
const [users, totalCount] = await Promise.all([
db.user.findMany({
where,
select: userListSelect,
orderBy,
skip,
take: safePageSize,
}),
db.user.count({ where }),
]);
return {
users,
totalCount,
page: safePage,
pageSize: safePageSize,
totalPages: Math.ceil(totalCount / safePageSize),
};
}
Index exports
Every domain service must be exported via src/services/index.ts:
// src/services/index.ts
export * from "./users/get-users";
export * from "./events/get-event-previews";
If you also keep per-domain index.ts files, export them from the root.
Usage in Next.js Server Components
In a Server Component:
import { getUsers } from "~/services";
export default async function UsersPage({ searchParams }: { searchParams: Record<string, string | string[]> }) {
const page = Number(searchParams.page ?? 1);
const data = await getUsers({
page,
pageSize: 20,
search: typeof searchParams.q === "string" ? searchParams.q : "",
sortBy: "createdAt",
sortOrder: "desc",
});
return (
<div>
<div>Total: {data.totalCount}</div>
{/* render data.users */}
</div>
);
}
Advanced guidance
Sorting whitelist (required)
Never do:
orderBy: { [sortBy]: sortOrder }
unless sortBy is a union of known keys (or validated via a whitelist map).
Preferred pattern:
const SORT_KEYS = {
createdAt: "createdAt",
name: "name",
email: "email",
} as const;
type SortBy = keyof typeof SORT_KEYS;
Search performance
For large tables:
- Ensure indexes exist for high-selectivity filters.
- Consider full-text search or trigram indexes in Postgres if
containssearch becomes slow (only if the user asks for scaling guidance).
When count is too slow
If count becomes a bottleneck (complex filters/joins), you may:
- keep
findManyin Prisma - use parameterized raw SQL for the count query (read-only) per the Prisma querying skill
- document why this exception is used
Output format when implementing a new service
When asked to create a service, output:
types/<domain>/...select + payload type (if missing)services/<domain>/<service>.tsimplementationservices/index.tsexport- Example Server Component usage
Cross-skill references
- Prisma type-settings skill: all service return shapes that include Prisma model data must be typed via
Prisma.validator()+GetPayloadintypes/<domain>/.... - Prisma database-querying skill: raw SQL is acceptable for SELECT/COUNT only when Prisma cannot express the query efficiently; mutations stay in Prisma Client or tRPC controllers unless specified.