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 100+ 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
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 |
Working With 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
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.