GraphQL API Development: Complete Guide with Node.js
Master GraphQL API development from scratch. Learn schema design, resolvers, queries, mutations, subscriptions, and authentication best practices.
Moshiour Rahman
Advertisement
What is GraphQL?
GraphQL is a query language for APIs that allows clients to request exactly the data they need. Unlike REST, it provides a single endpoint and lets clients specify their data requirements.
GraphQL vs REST
| REST | GraphQL |
|---|---|
| Multiple endpoints | Single endpoint |
| Fixed response shape | Flexible queries |
| Over/under fetching | Exact data needed |
| Multiple requests | Single request |
Getting Started
Setup with Apollo Server
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node ts-node
Basic Server
// src/index.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `#graphql
type Query {
hello: String
}
`;
const resolvers = {
Query: {
hello: () => 'Hello, GraphQL!'
}
};
const server = new ApolloServer({
typeDefs,
resolvers
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 }
});
console.log(`Server running at ${url}`);
Schema Definition
Types
# Scalar types
type User {
id: ID!
name: String!
email: String!
age: Int
balance: Float
isActive: Boolean!
createdAt: String
}
# Object types
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
}
# Enum types
enum Role {
USER
ADMIN
MODERATOR
}
# Input types
input CreateUserInput {
name: String!
email: String!
password: String!
role: Role = USER
}
input UpdateUserInput {
name: String
email: String
}
Queries and Mutations
type Query {
# Get single item
user(id: ID!): User
post(id: ID!): Post
# Get lists
users(limit: Int, offset: Int): [User!]!
posts(published: Boolean): [Post!]!
# Search
searchUsers(query: String!): [User!]!
}
type Mutation {
# Create
createUser(input: CreateUserInput!): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
# Update
updateUser(id: ID!, input: UpdateUserInput!): User
publishPost(id: ID!): Post
# Delete
deleteUser(id: ID!): Boolean!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
userOnline(userId: ID!): User!
}
Resolvers
Basic Resolvers
// src/resolvers.ts
const users = [
{ id: '1', name: 'John', email: 'john@example.com', isActive: true },
{ id: '2', name: 'Jane', email: 'jane@example.com', isActive: true }
];
const posts = [
{ id: '1', title: 'GraphQL Guide', content: '...', published: true, authorId: '1' },
{ id: '2', title: 'Node.js Tips', content: '...', published: false, authorId: '2' }
];
const resolvers = {
Query: {
user: (_, { id }) => users.find(u => u.id === id),
users: (_, { limit = 10, offset = 0 }) =>
users.slice(offset, offset + limit),
post: (_, { id }) => posts.find(p => p.id === id),
posts: (_, { published }) =>
published !== undefined
? posts.filter(p => p.published === published)
: posts
},
Mutation: {
createUser: (_, { input }) => {
const user = { id: String(users.length + 1), ...input, isActive: true };
users.push(user);
return user;
},
updateUser: (_, { id, input }) => {
const index = users.findIndex(u => u.id === id);
if (index === -1) return null;
users[index] = { ...users[index], ...input };
return users[index];
},
deleteUser: (_, { id }) => {
const index = users.findIndex(u => u.id === id);
if (index === -1) return false;
users.splice(index, 1);
return true;
}
},
// Field resolvers
Post: {
author: (post) => users.find(u => u.id === post.authorId)
},
User: {
posts: (user) => posts.filter(p => p.authorId === user.id)
}
};
Context and Authentication
// src/context.ts
import { verifyToken } from './auth';
export interface Context {
user: User | null;
dataSources: DataSources;
}
export async function createContext({ req }): Promise<Context> {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
user = await verifyToken(token);
} catch (error) {
// Invalid token
}
}
return {
user,
dataSources: {
users: new UserDataSource(),
posts: new PostDataSource()
}
};
}
// Usage in resolvers
const resolvers = {
Query: {
me: (_, __, context) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return context.user;
}
},
Mutation: {
createPost: (_, { input }, context) => {
if (!context.user) {
throw new Error('Not authenticated');
}
return context.dataSources.posts.create({
...input,
authorId: context.user.id
});
}
}
};
Database Integration
With Prisma
// src/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
// src/resolvers.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const resolvers = {
Query: {
users: () => prisma.user.findMany(),
user: (_, { id }) => prisma.user.findUnique({ where: { id } }),
posts: (_, { published }) =>
prisma.post.findMany({
where: published !== undefined ? { published } : undefined,
include: { author: true }
})
},
Mutation: {
createUser: (_, { input }) =>
prisma.user.create({ data: input }),
createPost: (_, { title, content, authorId }) =>
prisma.post.create({
data: { title, content, authorId },
include: { author: true }
}),
publishPost: (_, { id }) =>
prisma.post.update({
where: { id },
data: { published: true }
})
},
User: {
posts: (user) => prisma.post.findMany({ where: { authorId: user.id } })
}
};
Advanced Features
Pagination
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
const resolvers = {
Query: {
posts: async (_, { first = 10, after }) => {
const cursor = after ? { id: after } : undefined;
const posts = await prisma.post.findMany({
take: first + 1,
skip: cursor ? 1 : 0,
cursor,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
cursor: post.id,
node: post
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await prisma.post.count()
};
}
}
};
DataLoader (N+1 Problem)
import DataLoader from 'dataloader';
// Create loaders
const createLoaders = () => ({
userLoader: new DataLoader(async (ids: string[]) => {
const users = await prisma.user.findMany({
where: { id: { in: ids } }
});
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id));
}),
postsByAuthorLoader: new DataLoader(async (authorIds: string[]) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: authorIds } }
});
const postMap = new Map<string, Post[]>();
posts.forEach(post => {
const existing = postMap.get(post.authorId) || [];
postMap.set(post.authorId, [...existing, post]);
});
return authorIds.map(id => postMap.get(id) || []);
})
});
// Use in resolvers
const resolvers = {
Post: {
author: (post, _, { loaders }) =>
loaders.userLoader.load(post.authorId)
},
User: {
posts: (user, _, { loaders }) =>
loaders.postsByAuthorLoader.load(user.id)
}
};
Subscriptions
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const typeDefs = `#graphql
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
`;
const resolvers = {
Mutation: {
createPost: async (_, { input }, context) => {
const post = await prisma.post.create({ data: input });
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
addComment: async (_, { postId, text }, context) => {
const comment = await prisma.comment.create({
data: { postId, text, authorId: context.user.id }
});
pubsub.publish(`COMMENT_ADDED_${postId}`, { commentAdded: comment });
return comment;
}
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED'])
},
commentAdded: {
subscribe: (_, { postId }) =>
pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
}
}
};
// Setup WebSocket server
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
useServer({ schema }, wsServer);
Error Handling
import { GraphQLError } from 'graphql';
const resolvers = {
Mutation: {
createUser: async (_, { input }) => {
const existing = await prisma.user.findUnique({
where: { email: input.email }
});
if (existing) {
throw new GraphQLError('Email already exists', {
extensions: {
code: 'BAD_USER_INPUT',
field: 'email'
}
});
}
return prisma.user.create({ data: input });
}
}
};
// Custom error formatting
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// Log error
console.error(error);
// Hide internal errors in production
if (process.env.NODE_ENV === 'production') {
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new GraphQLError('Internal server error');
}
}
return error;
}
});
Directives
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION
type Query {
publicPosts: [Post!]!
myPosts: [Post!]! @auth
adminData: AdminData! @auth(requires: ADMIN)
oldField: String @deprecated(reason: "Use newField instead")
}
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
const { requires } = authDirective;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Not authenticated');
}
if (requires && context.user.role !== requires) {
throw new GraphQLError('Not authorized');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
}
});
}
Client Usage
Query Examples
# Get user with posts
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
published
}
}
}
# Search with variables
query SearchPosts($query: String!, $limit: Int) {
searchPosts(query: $query, limit: $limit) {
id
title
author {
name
}
}
}
# Fragments
fragment UserFields on User {
id
name
email
}
query GetUsers {
users {
...UserFields
posts {
title
}
}
}
React Client
import { ApolloClient, InMemoryCache, ApolloProvider, useQuery, useMutation } from '@apollo/client';
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache()
});
// Query hook
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Mutation hook
const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
`;
function CreateUserForm() {
const [createUser, { loading }] = useMutation(CREATE_USER, {
refetchQueries: [{ query: GET_USERS }]
});
const handleSubmit = (e) => {
e.preventDefault();
createUser({
variables: {
input: { name: 'New User', email: 'new@example.com' }
}
});
};
return <button onClick={handleSubmit} disabled={loading}>Create User</button>;
}
Summary
| Concept | Purpose |
|---|---|
| Schema | Define types and operations |
| Resolvers | Implement data fetching |
| Context | Share auth and data sources |
| DataLoader | Solve N+1 problem |
| Subscriptions | Real-time updates |
| Directives | Declarative field behavior |
GraphQL provides flexible, efficient API development with strong typing and excellent tooling.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Bun: The Fast JavaScript Runtime and Toolkit
Master Bun for JavaScript development. Learn the runtime, package manager, bundler, test runner, and build faster applications with Bun.
JavaScriptMongoDB with Node.js: Complete Database Guide
Master MongoDB with Node.js and Mongoose. Learn CRUD operations, schema design, indexing, aggregation pipelines, and production best practices.
JavaScriptDeno 2.0: The Modern JavaScript Runtime That's Ready for Production
Master Deno 2.0 for modern JavaScript and TypeScript development. Learn built-in tooling, npm compatibility, security model, and migration from Node.js.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.