GraphQL API Development: When to Use It and How to Build It Right
GraphQL API development in 2026 — schema design, resolvers, N+1 prevention with DataLoader, authentication, subscriptions, performance, and cost comparison with
GraphQL API Development: When to Use It and How to Build It Right
By Viprasol Tech Team
GraphQL is not a replacement for REST — it's a solution to specific problems that REST handles poorly. Understanding which problems it solves (and which it doesn't) is the prerequisite for making a good architecture decision.
GraphQL solves: over-fetching (REST returns fields you don't need), under-fetching (REST requires multiple requests to get related data), and documentation drift (the schema is always up to date because it's the source of truth). It introduces: query complexity attacks, N+1 database query problems, caching complexity, and a steeper learning curve for clients and developers.
This guide covers when GraphQL is the right choice, how to build a production-grade GraphQL API, and the problems you'll encounter (and must solve) along the way.
When GraphQL Makes Sense
Use GraphQL when:
- You have a complex data graph where different clients need different subsets of related data (mobile needs less than web, different pages need different combinations)
- You're building a public API where developers will query diverse combinations of data
- Your frontend is complex with many components that each need specific data slices
- Real-time subscriptions are a core feature
Stick with REST when:
- Your API has straightforward CRUD endpoints
- File uploads are a primary use case (GraphQL handles these awkwardly)
- You need HTTP caching at the CDN level (GraphQL POST requests don't cache by default)
- The client teams are not experienced with GraphQL
Schema Design: The Foundation
Schema-first design — define the GraphQL schema before writing resolvers. The schema is the contract between API and clients:
# schema.graphql
type Query {
project(id: ID!): Project
projects(
first: Int = 20
after: String
filter: ProjectFilter
): ProjectConnection!
me: User!
}
type Mutation {
createProject(input: CreateProjectInput!): CreateProjectPayload!
updateProject(id: ID!, input: UpdateProjectInput!): UpdateProjectPayload!
deleteProject(id: ID!): DeleteProjectPayload!
}
type Subscription {
projectUpdated(id: ID!): Project!
}
type Project {
id: ID!
name: String!
description: String
status: ProjectStatus!
owner: User!
members: [User!]!
tasks(status: TaskStatus): [Task!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type ProjectConnection {
edges: [ProjectEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProjectEdge {
node: Project!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
enum ProjectStatus {
ACTIVE
ON_HOLD
COMPLETED
ARCHIVED
}
input CreateProjectInput {
name: String!
description: String
visibility: Visibility = PRIVATE
}
type CreateProjectPayload {
project: Project
errors: [UserError!]!
}
type UserError {
field: String
message: String!
}
Relay-style connections (ProjectConnection, ProjectEdge, PageInfo) are the standard for paginated lists — they support cursor-based pagination and are expected by frontend GraphQL clients like Relay and Apollo.
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Resolvers with TypeScript and Pothos
Pothos (formerly GiraphQL) is the best TypeScript-first schema builder — type-safe resolvers without code generation:
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
const builder = new SchemaBuilder<{
Context: { userId: string; db: PrismaClient };
PrismaTypes: PrismaTypes;
}>({
plugins: [PrismaPlugin],
prisma: { client: db },
});
builder.prismaObject('Project', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
description: t.exposeString('description', { nullable: true }),
status: t.exposeString('status'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
owner: t.relation('owner'),
// Filtered tasks sub-selection
tasks: t.prismaField({
type: ['Task'],
nullable: false,
args: {
status: t.arg({ type: 'String', required: false }),
},
resolve: (query, project, { status }) =>
db.task.findMany({
...query,
where: {
projectId: project.id,
...(status ? { status } : {}),
},
orderBy: { createdAt: 'desc' },
}),
}),
}),
});
builder.queryField('project', (t) =>
t.prismaField({
type: 'Project',
nullable: true,
args: {
id: t.arg.id({ required: true }),
},
resolve: async (query, _root, { id }, ctx) => {
const project = await db.project.findUnique({
...query,
where: { id: String(id) },
});
// Authorization: user must be owner or member
if (!project) return null;
const isMember = await db.projectMember.findUnique({
where: { projectId_userId: { projectId: project.id, userId: ctx.userId } },
});
if (project.ownerId !== ctx.userId && !isMember) return null;
return project;
},
})
);
The N+1 Problem and DataLoader
The most common GraphQL performance problem: requesting 100 projects, each with an owner field — causes 100 separate database queries for the owners (1 for projects + 100 for owners = 101 queries).
DataLoader solves this by batching and caching database requests within a single GraphQL request:
import DataLoader from 'dataloader';
// Create loaders per request (not global — avoids cross-request data leakage)
function createLoaders(db: PrismaClient) {
return {
userById: new DataLoader<string, User | null>(async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map(u => [u.id, u]));
// Must return in same order as input ids
return ids.map(id => userMap.get(id) ?? null);
}),
projectMembersByProjectId: new DataLoader<string, User[]>(async (projectIds) => {
const memberships = await db.projectMember.findMany({
where: { projectId: { in: [...projectIds] } },
include: { user: true },
});
const memberMap = new Map<string, User[]>();
for (const m of memberships) {
const existing = memberMap.get(m.projectId) ?? [];
memberMap.set(m.projectId, [...existing, m.user]);
}
return projectIds.map(id => memberMap.get(id) ?? []);
}),
};
}
// In the resolver — DataLoader batches all owner requests into one query
builder.prismaObject('Project', {
fields: (t) => ({
owner: t.field({
type: 'User',
resolve: (project, _args, ctx) =>
ctx.loaders.userById.load(project.ownerId),
// DataLoader batches 100 calls into: SELECT * FROM users WHERE id IN (...)
}),
}),
});
100 projects → 2 queries total (projects + users), not 101. DataLoader is not optional in production GraphQL — it's the difference between 100ms and 10 second response times.

🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Recommended Reading
Authentication and Authorization in GraphQL
GraphQL has a single endpoint (/graphql) unlike REST's many endpoints. Authentication is handled at the HTTP level (JWT verification before the GraphQL execution begins); authorization is handled in resolvers:
// Context builder — runs before every request
async function buildContext({ req }: { req: Request }) {
const token = req.headers.authorization?.replace('Bearer ', '');
let userId: string | null = null;
if (token) {
try {
const payload = await verifyAccessToken(token);
userId = payload.sub;
} catch {
// Invalid token — context has null userId
}
}
return {
userId,
db,
loaders: createLoaders(db),
};
}
// Pothos auth plugin for field-level authorization
builder.queryField('adminStats', (t) =>
t.field({
type: AdminStats,
authScopes: { isAdmin: true }, // only accessible to admin users
resolve: () => getAdminStats(),
})
);
Subscriptions (Real-Time Updates)
GraphQL subscriptions use WebSocket for server-to-client push:
import { createServer } from 'http';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer({
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authorization as string | undefined;
const userId = token ? await verifyTokenOrNull(token) : null;
return { userId, db, loaders: createLoaders(db) };
},
}, wsServer);
// Resolver using PubSub
const resolvers = {
Subscription: {
projectUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['PROJECT_UPDATED']),
(payload, variables, context) =>
payload.projectUpdated.id === variables.id &&
canUserAccessProject(context.userId, payload.projectUpdated.id),
),
},
},
};
Query Complexity and Depth Limiting
GraphQL's flexible queries can be weaponized — a deeply nested query can generate thousands of database calls:
# Malicious deep query
{ project { tasks { assignee { projects { tasks { assignee { ... } } } } } } }
Protect with graphql-depth-limit and query complexity analysis:
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(7), // max 7 levels of nesting
createComplexityRule({
maximumComplexity: 1000, // each field has a cost; total must be < 1000
variables: {},
onComplete: (complexity) => {
if (complexity > 500) console.warn(`High query complexity: ${complexity}`);
},
}),
],
});
Cost Ranges for GraphQL API Development
| Project Type | Scope | Cost Range | Timeline |
|---|---|---|---|
| GraphQL API (replacing REST) | 20–40 types + resolvers | $30K–$80K | 8–14 weeks |
| GraphQL with subscriptions | Real-time + WebSocket | $50K–$120K | 3–5 months |
| Public GraphQL API + federation | Multi-service schema federation | $80K–$200K | 4–8 months |
Inside Viprasol
Our API development practice includes GraphQL schema design, Pothos type-safe resolvers, DataLoader optimization, subscriptions, and query complexity protection. We use TypeScript throughout and code-generate types from the schema for complete end-to-end type safety.
Building a GraphQL API? Viprasol Tech designs and implements production GraphQL APIs. Contact us.
See also: API Development Company · Node.js Development Company · React Development Company
Sources: GraphQL Specification · Pothos Documentation · DataLoader Documentation
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.