Build a GraphQL server with schema-first design
Builds production-ready GraphQL servers with Apollo, DataLoader, and real-time subscriptions.
/plugin marketplace add jeremylongshore/claude-code-plugins-plus-skills/plugin install grpc-service-generator@claude-code-plugins-plusCreate a production-ready GraphQL server with type-safe schemas, optimized resolvers, real-time subscriptions, authentication, and comprehensive tooling using schema-first design principles.
Use /build-graphql-server when you need to:
DON'T use this when:
This command implements Apollo Server with DataLoader as the primary approach because:
Alternative considered: GraphQL Yoga
Alternative considered: Mercurius (Fastify)
Before running this command:
Create comprehensive type definitions with proper nullability and relationships.
Build efficient resolvers with DataLoader for batching and caching.
Implement context-based auth with field-level permissions.
Configure WebSocket server for real-time updates.
Add query complexity analysis, depth limiting, and caching.
The command generates:
schema/ - GraphQL schema definitionsresolvers/ - Resolver implementationsdataloaders/ - DataLoader configurationsdirectives/ - Custom schema directivesserver.js - Apollo Server setupgenerated/types.ts - TypeScript definitionstests/ - Integration and unit tests// schema/schema.graphql
scalar DateTime
scalar Upload
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int, window: String) on FIELD_DEFINITION
directive @deprecated(reason: String) on FIELD_DEFINITION | ENUM_VALUE
enum Role {
USER
MODERATOR
ADMIN
}
type User {
id: ID!
username: String!
email: String!
role: Role!
profile: Profile
posts(
first: Int
after: String
orderBy: PostOrderBy
filter: PostFilter
): PostConnection!
followers: [User!]!
following: [User!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Profile {
bio: String
avatar: String
website: String
location: String
}
type Post {
id: ID!
title: String!
content: String!
excerpt: String
author: User!
tags: [Tag!]!
comments(first: Int, after: String): CommentConnection!
likes: Int!
views: Int!
published: Boolean!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
parentComment: Comment
replies: [Comment!]!
likes: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Tag {
id: ID!
name: String!
slug: String!
posts(first: Int, after: String): PostConnection!
}
# Relay-style pagination types
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
node: Comment!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Input types
input CreateUserInput {
username: String!
email: String!
password: String!
profile: ProfileInput
}
input ProfileInput {
bio: String
avatar: String
website: String
location: String
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
published: Boolean
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
input PostFilter {
published: Boolean
authorId: ID
tags: [String!]
searchQuery: String
}
enum PostOrderBy {
CREATED_AT_DESC
CREATED_AT_ASC
LIKES_DESC
VIEWS_DESC
TITLE_ASC
}
# Root types
type Query {
# User queries
me: User @auth
user(id: ID!): User
users(
first: Int
after: String
filter: UserFilter
): UserConnection!
# Post queries
post(id: ID!): Post
posts(
first: Int
after: String
orderBy: PostOrderBy
filter: PostFilter
): PostConnection! @rateLimit(max: 100, window: "1m")
searchPosts(query: String!, first: Int): [Post!]!
trendingPosts(limit: Int = 10): [Post!]!
# Tag queries
tag(slug: String!): Tag
tags: [Tag!]!
}
type Mutation {
# Authentication
signUp(input: CreateUserInput!): AuthPayload!
signIn(email: String!, password: String!): AuthPayload!
signOut: Boolean! @auth
# User mutations
updateProfile(input: ProfileInput!): User! @auth
followUser(userId: ID!): User! @auth
unfollowUser(userId: ID!): User! @auth
# Post mutations
createPost(input: CreatePostInput!): Post! @auth
updatePost(id: ID!, input: UpdatePostInput!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: MODERATOR)
likePost(postId: ID!): Post! @auth
# Comment mutations
addComment(postId: ID!, content: String!, parentId: ID): Comment! @auth
updateComment(id: ID!, content: String!): Comment! @auth
deleteComment(id: ID!): Boolean! @auth
}
type Subscription {
# Post subscriptions
postCreated(authorId: ID): Post!
postUpdated(id: ID!): Post!
postDeleted: ID!
# Comment subscriptions
commentAdded(postId: ID!): Comment!
commentUpdated(postId: ID!): Comment!
# User activity
userOnlineStatus(userId: ID!): UserStatus!
}
type AuthPayload {
token: String!
user: User!
}
type UserStatus {
userId: ID!
online: Boolean!
lastSeen: DateTime
}
// server.js - Apollo Server setup
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { ApolloServerPluginDrainHttpServer } = require('@apollo/server/plugin/drainHttpServer');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const express = require('express');
const http = require('http');
const cors = require('cors');
const DataLoader = require('dataloader');
const depthLimit = require('graphql-depth-limit');
const costAnalysis = require('graphql-cost-analysis');
// Import schema and resolvers
const typeDefs = require('./schema/typeDefs');
const resolvers = require('./resolvers');
const { authDirective } = require('./directives/auth');
const { rateLimitDirective } = require('./directives/rateLimit');
// Create executable schema with directives
let schema = makeExecutableSchema({
typeDefs,
resolvers
});
// Apply schema directives
schema = authDirective(schema, 'auth');
schema = rateLimitDirective(schema, 'rateLimit');
// Create Express app and HTTP server
const app = express();
const httpServer = http.createServer(app);
// Create WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
// Create DataLoaders for batching
function createDataLoaders(db) {
return {
userLoader: new DataLoader(async (userIds) => {
const users = await db.user.findMany({
where: { id: { in: userIds } }
});
const userMap = new Map(users.map(user => [user.id, user]));
return userIds.map(id => userMap.get(id));
}),
postLoader: new DataLoader(async (postIds) => {
const posts = await db.post.findMany({
where: { id: { in: postIds } }
});
const postMap = new Map(posts.map(post => [post.id, post]));
return postIds.map(id => postMap.get(id));
}),
commentsByPostLoader: new DataLoader(async (postIds) => {
const comments = await db.comment.findMany({
where: { postId: { in: postIds } },
orderBy: { createdAt: 'desc' }
});
const commentsByPost = {};
comments.forEach(comment => {
if (!commentsByPost[comment.postId]) {
commentsByPost[comment.postId] = [];
}
commentsByPost[comment.postId].push(comment);
});
return postIds.map(id => commentsByPost[id] || []);
})
};
}
// Configure WebSocket server for subscriptions
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
// Return context for subscriptions
return {
db,
pubsub,
user: await authenticateWebSocket(ctx.connectionParams)
};
}
},
wsServer
);
// Create Apollo Server
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
}
};
}
}
],
validationRules: [
depthLimit(10),
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
scalarCost: 1,
objectCost: 10,
listFactor: 10
})
],
formatError: (error) => {
// Custom error formatting
console.error(error);
// Remove stack trace in production
if (process.env.NODE_ENV === 'production') {
delete error.extensions.stacktrace;
}
return {
message: error.message,
extensions: {
code: error.extensions.code,
timestamp: Date.now()
}
};
}
});
// Start server
async function startServer() {
await server.start();
app.use(
'/graphql',
cors(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
// Get the user from JWT token
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
// Create DataLoaders for this request
const loaders = createDataLoaders(db);
return {
db,
user,
loaders,
pubsub,
req
};
}
})
);
await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve));
console.log(`🚀 Server ready at http://localhost:4000/graphql`);
}
startServer().catch(console.error);
// resolvers/index.js
const { GraphQLScalarType } = require('graphql');
const { PubSub, withFilter } = require('graphql-subscriptions');
const DataLoader = require('dataloader');
const pubsub = new PubSub();
const resolvers = {
// Custom scalar for DateTime
DateTime: new GraphQLScalarType({
name: 'DateTime',
serialize: (value) => value.toISOString(),
parseValue: (value) => new Date(value),
parseLiteral: (ast) => new Date(ast.value)
}),
Query: {
me: async (_, __, { user, loaders }) => {
if (!user) throw new Error('Not authenticated');
return loaders.userLoader.load(user.id);
},
user: async (_, { id }, { loaders }) => {
return loaders.userLoader.load(id);
},
users: async (_, { first = 10, after, filter }, { db }) => {
const cursor = after ? { id: after } : undefined;
const users = await db.user.findMany({
take: first + 1,
skip: cursor ? 1 : 0,
cursor,
where: filter,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = users.length > first;
const edges = users.slice(0, first).map(user => ({
node: user,
cursor: user.id
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await db.user.count({ where: filter })
};
},
post: async (_, { id }, { loaders }) => {
const post = await loaders.postLoader.load(id);
if (post && !post.published) {
// Check if user can view unpublished posts
const canView = await checkPostPermission(post, user);
if (!canView) return null;
}
return post;
},
posts: async (_, { first = 10, after, orderBy = 'CREATED_AT_DESC', filter }, { db }) => {
// Build where clause from filter
const where = {};
if (filter) {
if (filter.published !== undefined) {
where.published = filter.published;
}
if (filter.authorId) {
where.authorId = filter.authorId;
}
if (filter.tags?.length) {
where.tags = {
some: {
name: { in: filter.tags }
}
};
}
if (filter.searchQuery) {
where.OR = [
{ title: { contains: filter.searchQuery, mode: 'insensitive' } },
{ content: { contains: filter.searchQuery, mode: 'insensitive' } }
];
}
}
// Build orderBy from enum
const orderByMap = {
CREATED_AT_DESC: { createdAt: 'desc' },
CREATED_AT_ASC: { createdAt: 'asc' },
LIKES_DESC: { likes: 'desc' },
VIEWS_DESC: { views: 'desc' },
TITLE_ASC: { title: 'asc' }
};
const cursor = after ? { id: after } : undefined;
const posts = await db.post.findMany({
take: first + 1,
skip: cursor ? 1 : 0,
cursor,
where,
orderBy: orderByMap[orderBy],
include: {
_count: {
select: { comments: true, likes: true }
}
}
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post.id
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await db.post.count({ where })
};
},
searchPosts: async (_, { query, first = 10 }, { db }) => {
return db.post.findMany({
where: {
published: true,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
{ tags: { some: { name: { contains: query, mode: 'insensitive' } } } }
]
},
take: first,
orderBy: { createdAt: 'desc' }
});
},
trendingPosts: async (_, { limit = 10 }, { db }) => {
// Get trending posts based on recent engagement
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return db.post.findMany({
where: {
published: true,
OR: [
{ likes: { gte: 10 } },
{ comments: { some: { createdAt: { gte: oneDayAgo } } } }
]
},
orderBy: [
{ likes: 'desc' },
{ views: 'desc' },
{ createdAt: 'desc' }
],
take: limit
});
}
},
Mutation: {
signUp: async (_, { input }, { db }) => {
// Validate input
const existingUser = await db.user.findUnique({
where: { email: input.email }
});
if (existingUser) {
throw new Error('User already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(input.password, 10);
// Create user with profile
const user = await db.user.create({
data: {
username: input.username,
email: input.email,
password: hashedPassword,
profile: input.profile ? {
create: input.profile
} : undefined
},
include: { profile: true }
});
// Generate JWT token
const token = generateToken(user);
return { token, user };
},
createPost: async (_, { input }, { db, user, pubsub }) => {
if (!user) throw new Error('Not authenticated');
const post = await db.post.create({
data: {
...input,
authorId: user.id,
tags: input.tags ? {
connectOrCreate: input.tags.map(tag => ({
where: { slug: slugify(tag) },
create: { name: tag, slug: slugify(tag) }
}))
} : undefined
},
include: {
author: true,
tags: true
}
});
// Publish subscription event
pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
updatePost: async (_, { id, input }, { db, user }) => {
if (!user) throw new Error('Not authenticated');
// Check ownership
const post = await db.post.findUnique({
where: { id }
});
if (!post) throw new Error('Post not found');
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new Error('Not authorized');
}
// Update post
const updatedPost = await db.post.update({
where: { id },
data: {
...input,
tags: input.tags ? {
set: [],
connectOrCreate: input.tags.map(tag => ({
where: { slug: slugify(tag) },
create: { name: tag, slug: slugify(tag) }
}))
} : undefined
},
include: {
author: true,
tags: true
}
});
// Publish subscription event
pubsub.publish('POST_UPDATED', { postUpdated: updatedPost });
return updatedPost;
},
likePost: async (_, { postId }, { db, user }) => {
if (!user) throw new Error('Not authenticated');
// Check if already liked
const existingLike = await db.like.findUnique({
where: {
userId_postId: {
userId: user.id,
postId
}
}
});
if (existingLike) {
// Unlike
await db.like.delete({
where: { id: existingLike.id }
});
return db.post.update({
where: { id: postId },
data: { likes: { decrement: 1 } },
include: { author: true, tags: true }
});
} else {
// Like
await db.like.create({
data: {
userId: user.id,
postId
}
});
return db.post.update({
where: { id: postId },
data: { likes: { increment: 1 } },
include: { author: true, tags: true }
});
}
}
},
Subscription: {
postCreated: {
subscribe: withFilter(
() => pubsub.asyncIterator(['POST_CREATED']),
(payload, variables) => {
// Filter by authorId if provided
if (variables.authorId) {
return payload.postCreated.authorId === variables.authorId;
}
return true;
}
)
},
commentAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(['COMMENT_ADDED']),
(payload, variables) => {
return payload.commentAdded.postId === variables.postId;
}
)
}
},
// Field resolvers for relationships
User: {
posts: async (user, { first = 10, after }, { db }) => {
// Implement cursor-based pagination for user posts
const cursor = after ? { id: after } : undefined;
const posts = await db.post.findMany({
where: { authorId: user.id },
take: first + 1,
skip: cursor ? 1 : 0,
cursor,
orderBy: { createdAt: 'desc' }
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
node: post,
cursor: post.id
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: await db.post.count({ where: { authorId: user.id } })
};
},
followers: async (user, _, { loaders }) => {
return loaders.followersLoader.load(user.id);
},
following: async (user, _, { loaders }) => {
return loaders.followingLoader.load(user.id);
}
},
Post: {
author: async (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
},
comments: async (post, { first = 10, after }, { db }) => {
// Use DataLoader for better performance
const comments = await loaders.commentsByPostLoader.load(post.id);
// Apply pagination
const startIndex = after ? comments.findIndex(c => c.id === after) + 1 : 0;
const paginatedComments = comments.slice(startIndex, startIndex + first + 1);
const hasNextPage = paginatedComments.length > first;
const edges = paginatedComments.slice(0, first).map(comment => ({
node: comment,
cursor: comment.id
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: comments.length
};
}
},
Comment: {
author: async (comment, _, { loaders }) => {
return loaders.userLoader.load(comment.authorId);
},
post: async (comment, _, { loaders }) => {
return loaders.postLoader.load(comment.postId);
}
}
};
module.exports = resolvers;
// directives/auth.js
const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');
const { defaultFieldResolver } = require('graphql');
function authDirective(schema, directiveName) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
// Check if user is authenticated
if (!context.user) {
throw new Error('Not authenticated');
}
// Check role requirements
if (requires) {
const hasRole = checkUserRole(context.user, requires);
if (!hasRole) {
throw new Error(`Requires ${requires} role`);
}
}
return resolve(source, args, context, info);
};
return fieldConfig;
}
}
});
}
// directives/rateLimit.js
const { RateLimiterMemory } = require('rate-limiter-flexible');
const rateLimiters = new Map();
function rateLimitDirective(schema, directiveName) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const rateLimitDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (rateLimitDirective) {
const { max, window } = rateLimitDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
// Create rate limiter for this field
const key = `${fieldConfig.name}_${max}_${window}`;
if (!rateLimiters.has(key)) {
rateLimiters.set(key, new RateLimiterMemory({
points: max,
duration: parseDuration(window)
}));
}
const limiter = rateLimiters.get(key);
fieldConfig.resolve = async function (source, args, context, info) {
const userId = context.user?.id || context.req.ip;
try {
await limiter.consume(userId);
} catch (e) {
throw new Error(`Rate limit exceeded. Max ${max} requests per ${window}`);
}
return resolve(source, args, context, info);
};
return fieldConfig;
}
}
});
}
// services/caching.js
const Redis = require('ioredis');
const { createHash } = require('crypto');
class GraphQLCache {
constructor(redis = new Redis()) {
this.redis = redis;
}
async get(key) {
const cached = await this.redis.get(key);
return cached ? JSON.parse(cached) : null;
}
async set(key, value, ttl = 300) {
await this.redis.setex(key, ttl, JSON.stringify(value));
}
createCacheKey(info, args) {
const query = info.fieldName;
const argsString = JSON.stringify(args);
return createHash('md5').update(`${query}:${argsString}`).digest('hex');
}
// Cache resolver wrapper
cacheResolver(resolver, options = {}) {
const { ttl = 300 } = options;
return async (source, args, context, info) => {
// Skip caching for mutations and subscriptions
if (info.operation.operation !== 'query') {
return resolver(source, args, context, info);
}
const cacheKey = this.createCacheKey(info, args);
const cached = await this.get(cacheKey);
if (cached) {
return cached;
}
const result = await resolver(source, args, context, info);
await this.set(cacheKey, result, ttl);
return result;
};
}
}
module.exports = { authDirective, rateLimitDirective, GraphQLCache };
| Error | Cause | Solution |
|---|---|---|
| "Query depth limit exceeded" | Query too deeply nested | Adjust depth limit or simplify query |
| "Query complexity too high" | Query too expensive | Optimize query or increase complexity budget |
| "N+1 query detected" | Missing DataLoader | Implement DataLoader for relationship |
| "Subscription connection failed" | WebSocket issues | Check WebSocket configuration |
| "Schema validation failed" | Invalid GraphQL schema | Fix schema syntax errors |
Server Options
introspection: Enable schema introspection (disable in production)playground: Enable GraphQL Playgroundcors: CORS configurationuploads: File upload supportsubscriptions: WebSocket configurationPerformance Options
depthLimit: Maximum query depth (default: 10)costAnalysis: Query cost limitsdataLoader: Batching configurationcaching: Response caching settingspersistedQueries: APQ supportDO:
DON'T:
/api-authentication-builder - Add auth to GraphQL/api-documentation-generator - Generate GraphQL docs/api-testing-framework - Test GraphQL APIs/graphql-federation - Implement microservices