Skip to main content

How to build a GraphQL API with NestJS?

GraphQL API with NestJS is an Apollo-backed setup where TypeScript class decorators define your schema, resolvers handle queries and mutations, and the framework wires everything together automatically.

Theory

TL;DR

  • Install @nestjs/graphql, @nestjs/apollo, @apollo/server, and graphql to get started
  • Code-first: write TypeScript classes with decorators, get a .gql schema generated on startup
  • Schema-first: write the SDL file yourself, NestJS generates TypeScript types from it
  • Resolvers replace controllers - @Query, @Mutation, @ResolveField replace HTTP verbs
  • DataLoader is the fix for N+1 queries on nested fields; skip it and nested relations will kill performance

Code-first vs schema-first

With code-first, your TypeScript classes are the schema. NestJS reads the decorators and writes schema.gql automatically. With schema-first, you author the .graphql SDL file and NestJS generates matching TypeScript types from it.

Most teams pick code-first because the TypeScript types and the GraphQL schema stay in sync without extra steps. Schema-first fits when a separate team owns the schema contract, or when you're sharing a schema package across multiple services.

Setup

bash
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql
typescript
// app.module.ts import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; @Module({ imports: [ GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: 'schema.gql', // scans decorators, writes schema on startup sortSchema: true, playground: true, // disable this in production }), ], }) export class AppModule {}

Set autoSchemaFile: true if you don't need the file on disk - the schema is generated in memory only.

Object types and input types

Every GraphQL type you return from a query needs @ObjectType(). Every field needs @Field(). NestJS cannot infer array types or GraphQL scalars like ID and Int from TypeScript alone, so you pass them explicitly as the first argument.

typescript
import { ObjectType, Field, ID, Int } from '@nestjs/graphql'; @ObjectType() export class User { @Field(() => ID) id: string; @Field() name: string; @Field(() => Int) age: number; @Field(() => [Post], { nullable: true }) posts?: Post[]; }

For mutations, use @InputType(). You can generate a partial version with PartialType - but import it from @nestjs/graphql, not @nestjs/mapped-types. The mapped-types version skips @Field() decorators on the generated fields.

typescript
import { InputType, Field, PartialType } from '@nestjs/graphql'; @InputType() export class CreateUserInput { @Field() name: string; @Field() email: string; @Field(() => Int) age: number; } @InputType() export class UpdateUserInput extends PartialType(CreateUserInput) {}

Resolvers

Resolvers are where business logic connects to GraphQL. @Resolver(() => User) binds the class to the User type, which matters specifically for @ResolveField - without it, field resolvers won't know which type they belong to.

typescript
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from '@nestjs/graphql'; @Resolver(() => User) export class UsersResolver { constructor( private usersService: UsersService, private postsService: PostsService, ) {} @Query(() => [User], { name: 'users' }) findAll() { return this.usersService.findAll(); } @Query(() => User, { name: 'user' }) findOne(@Args('id', { type: () => ID }) id: string) { return this.usersService.findOne(id); } @Mutation(() => User) createUser(@Args('input') input: CreateUserInput) { return this.usersService.create(input); } @Mutation(() => Boolean) deleteUser(@Args('id', { type: () => ID }) id: string) { return this.usersService.delete(id); } @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsService.findByUserId(user.id); // runs once per User in the result } }

@ResolveField runs for every object in the result set. Return 50 users and you make 50 separate calls to findByUserId. That is the N+1 problem.

Authentication

NestJS guards work in GraphQL with one required change: you extract the request from the GraphQL execution context, not the HTTP context.

typescript
import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { GqlExecutionContext } from '@nestjs/graphql'; @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; // reads the HTTP request from inside GraphQL context } } // In resolver @Query(() => User) @UseGuards(GqlAuthGuard) me(@CurrentUser() user: User) { return user; }

Without the getRequest override, the JWT strategy reads from the HTTP context which in GraphQL returns nothing. The guard passes silently and no token is validated.

Subscriptions

Subscriptions use WebSockets. You enable them in the module config and set up a PubSub instance to pass events between mutations and subscribers.

typescript
// Enable in module GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: 'schema.gql', subscriptions: { 'graphql-ws': true, // modern protocol; 'subscriptions-transport-ws' is legacy }, }) // In resolver @Subscription(() => Message, { filter: (payload, variables) => payload.messageAdded.roomId === variables.roomId, }) messageAdded(@Args('roomId') roomId: string) { return pubSub.asyncIterator('messageAdded'); } @Mutation(() => Message) async sendMessage(@Args('input') input: SendMessageInput) { const message = await this.messagesService.create(input); pubSub.publish('messageAdded', { messageAdded: message }); return message; }

For production, replace the in-memory PubSub from graphql-subscriptions with a Redis-backed one (graphql-redis-subscriptions). The in-memory version breaks when you run more than one server instance.

DataLoader and the N+1 problem

DataLoader batches all individual .load(id) calls made during a single tick into one function call. Instead of 50 database queries, you get one.

typescript
import DataLoader from 'dataloader'; import { Injectable, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.REQUEST }) // must be request-scoped, not singleton export class PostsLoader { constructor(private postsService: PostsService) {} readonly batchPosts = new DataLoader<string, Post[]>( async (userIds: string[]) => { const posts = await this.postsService.findByUserIds([...userIds]); // return results in the same order as input ids - DataLoader requires this return userIds.map((id) => posts.filter((p) => p.userId === id)); }, ); } // In resolver - replace direct service call with loader @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsLoader.batchPosts.load(user.id); }

scope: Scope.REQUEST is not optional. A singleton DataLoader lives forever and caches results from one request into the next. That means user A can see user B's data.

Common mistakes

Importing PartialType from the wrong package.

typescript
// Wrong - skips @Field() decorators on generated fields import { PartialType } from '@nestjs/mapped-types'; // Right - preserves @Field() for GraphQL schema generation import { PartialType } from '@nestjs/graphql';

Not scoping DataLoader to REQUEST.

typescript
// Wrong - caches data across all requests @Injectable() export class PostsLoader { ... } // Right - resets on every request @Injectable({ scope: Scope.REQUEST }) export class PostsLoader { ... }

Missing getRequest override in auth guards. The guard compiles and runs, but reads from the wrong context. No error is thrown. The token is never validated.

Leaving playground: true in production. The playground exposes your full schema to anyone who opens the URL. Disable it or add auth in front of it.

Using in-memory PubSub for subscriptions beyond one server instance. Events published on server A never reach clients connected to server B.

Real-world usage

  • Products with web and mobile clients that need different data shapes are the strongest case for GraphQL
  • @ResolveField + DataLoader handles deep nesting: User -> Posts -> Comments -> Likes
  • Subscriptions handle chat, notifications, and live dashboards
  • Schema-first works well in monorepos where the schema file is shared with frontend code generation tools like GraphQL Code Generator
  • The name option in @Query(() => User, { name: 'user' }) controls the operation name in the schema - by default NestJS uses the method name

Follow-up questions

Q: What is the difference between @Query and @ResolveField in NestJS?
A: @Query defines a top-level operation on the schema's Query type - it runs once per request. @ResolveField defines a field resolver for a specific object type - it runs once per object in the result, which is why N+1 happens there.

Q: Why does DataLoader require scope: Scope.REQUEST?
A: DataLoader caches results by key for the duration of its lifetime. A singleton DataLoader lives as long as the server, so cached results from one user's request persist into the next user's request. Request scope resets the cache with every incoming request.

Q: How does auth work differently in GraphQL vs REST in NestJS?
A: The JWT strategy is the same. The difference is in where the guard reads the request from. REST uses context.switchToHttp().getRequest(). In GraphQL you need GqlExecutionContext.create(context).getContext().req because the execution context has a different shape inside Apollo.

Q: When would you choose schema-first over code-first?
A: Schema-first fits when the GraphQL schema is a shared contract agreed between teams before implementation. If a frontend team and backend team define the schema upfront, schema-first keeps that contract as an explicit file. Code-first fits when one team owns both sides and prefers TypeScript as the single source of truth.

Q: You return 100 users each with a posts field. How many database queries run without DataLoader, and how many with it?
A: Without DataLoader: 101 queries (1 for users, 100 for posts). With DataLoader: 2 queries (1 for users, 1 batched call with all 100 user IDs). DataLoader collects all the .load(id) calls made in a single event loop tick, then fires one batch function.

Examples

Basic query and mutation resolver

Minimum wiring to get a working GraphQL endpoint with read and write operations.

typescript
// user.type.ts import { ObjectType, Field, ID } from '@nestjs/graphql'; @ObjectType() export class User { @Field(() => ID) id: string; @Field() name: string; @Field() email: string; } // create-user.input.ts import { InputType, Field } from '@nestjs/graphql'; @InputType() export class CreateUserInput { @Field() name: string; @Field() email: string; } // users.resolver.ts @Resolver(() => User) export class UsersResolver { constructor(private usersService: UsersService) {} @Query(() => [User], { name: 'users' }) findAll() { return this.usersService.findAll(); } @Mutation(() => User) createUser(@Args('input') input: CreateUserInput) { return this.usersService.create(input); } }

With the app running, open http://localhost:3000/graphql and run query { users { id name email } }. The schema was generated from decorators on startup.

Nested field resolution with DataLoader

This is the pattern that separates a working GraphQL API from a performant one.

typescript
// posts.loader.ts @Injectable({ scope: Scope.REQUEST }) export class PostsLoader { constructor(private postsService: PostsService) {} readonly batchPosts = new DataLoader<string, Post[]>( async (userIds: string[]) => { const posts = await this.postsService.findByUserIds([...userIds]); return userIds.map((id) => posts.filter((p) => p.userId === id)); }, ); } // users.resolver.ts - extend the existing resolver @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsLoader.batchPosts.load(user.id); }

query { users { name posts { title } } } now triggers exactly two database calls regardless of how many users are returned. I've seen this change take a GraphQL endpoint from 400ms to under 40ms on a real product - it's the single highest-impact optimization in any NestJS GraphQL codebase.

Protected mutation with JWT guard

typescript
// gql-auth.guard.ts @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; } } // users.resolver.ts @Mutation(() => User) @UseGuards(GqlAuthGuard) updateProfile( @CurrentUser() currentUser: User, @Args('input') input: UpdateUserInput, ) { return this.usersService.update(currentUser.id, input); }

The client sends Authorization: Bearer <token> as an HTTP header. The guard reads it through the GraphQL context bridge, validates the JWT, and @CurrentUser() pulls the decoded user off the request object. The mutation only executes if the token is valid.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?