Skip to content
NestJS projects 5 min read

Project: GraphQL Blog API

GraphQL lets clients ask for exactly the data they need in a single round trip, which makes it a natural fit for a blog where posts, authors, and comments are deeply related. In this project you’ll build a code-first GraphQL API in NestJS: object types defined from TypeScript classes, resolvers that fetch and mutate data, DataLoader to kill the N+1 query problem, JWT-backed auth guards, and live subscriptions over WebSockets. The code-first approach keeps your schema and types in one place, so refactors stay type-safe end to end.

Setting up the GraphQL module

NestJS wires GraphQL through @nestjs/graphql and a driver. We’ll use Apollo with autoSchemaFile, which generates the SDL from your decorated classes at startup. Subscriptions need a separate WebSocket transport, enabled with the subscriptions option.

npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql
npm install dataloader graphql-subscriptions
// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { PostsModule } from './posts/posts.module';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      sortSchema: true,
      subscriptions: {
        'graphql-ws': true,
      },
      context: ({ req }) => ({ req }),
    }),
    PostsModule,
  ],
})
export class AppModule {}

Object types and resolvers

Object types are plain classes annotated with @ObjectType() and @Field(). A resolver class groups the queries, mutations, and field resolvers for a type. Each @Query() or @Mutation() method becomes a root operation in the schema.

// posts/models/post.model.ts
import { ObjectType, Field, ID, Int } from '@nestjs/graphql';
import { Author } from '../../authors/models/author.model';

@ObjectType()
export class Post {
  @Field(() => ID)
  id: string;

  @Field()
  title: string;

  @Field()
  body: string;

  @Field(() => Int)
  authorId: number;

  @Field(() => Author)
  author: Author;
}
// posts/posts.resolver.ts
import { Resolver, Query, Args, ID } from '@nestjs/graphql';
import { Post } from './models/post.model';
import { PostsService } from './posts.service';

@Resolver(() => Post)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}

  @Query(() => [Post], { name: 'posts' })
  findAll(): Promise<Post[]> {
    return this.postsService.findAll();
  }

  @Query(() => Post, { name: 'post', nullable: true })
  findOne(@Args('id', { type: () => ID }) id: string): Promise<Post | null> {
    return this.postsService.findOne(id);
  }
}

Resolving relations with DataLoader

A naive @ResolveField for author runs one query per post — fetch 50 posts and you fire 51 queries. DataLoader batches all the author IDs requested within a single tick into one call and caches results per request. Register it as a request-scoped provider so the cache never leaks across requests.

// authors/author.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import * as DataLoader from 'dataloader';
import { Author } from './models/author.model';
import { AuthorsService } from './authors.service';

@Injectable({ scope: Scope.REQUEST })
export class AuthorLoader {
  constructor(private readonly authorsService: AuthorsService) {}

  readonly batchAuthors = new DataLoader<number, Author>(
    async (ids: readonly number[]) => {
      const authors = await this.authorsService.findByIds([...ids]);
      const map = new Map(authors.map((a) => [a.id, a]));
      return ids.map((id) => map.get(id) ?? null);
    },
  );
}
// posts/posts.resolver.ts (field resolver)
import { ResolveField, Parent } from '@nestjs/graphql';
import { AuthorLoader } from '../authors/author.loader';

@ResolveField(() => Author)
author(@Parent() post: Post): Promise<Author> {
  return this.authorLoader.batchAuthors.load(post.authorId);
}

The loader MUST be request-scoped (Scope.REQUEST). A singleton DataLoader would cache stale data and serve one user’s records to another.

Mutations with validation

Inputs use @InputType() classes, and class-validator decorators are enforced by a global ValidationPipe. Resolvers stay thin — they delegate to a service and return the persisted entity.

// posts/dto/create-post.input.ts
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNotEmpty, MaxLength } from 'class-validator';

@InputType()
export class CreatePostInput {
  @Field()
  @IsNotEmpty()
  @MaxLength(120)
  title: string;

  @Field()
  @IsNotEmpty()
  body: string;

  @Field(() => Int)
  authorId: number;
}
// posts/posts.resolver.ts (mutation)
import { Mutation } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
import { PubSub } from 'graphql-subscriptions';

const pubSub = new PubSub();

@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
async createPost(@Args('input') input: CreatePostInput): Promise<Post> {
  const post = await this.postsService.create(input);
  await pubSub.publish('postAdded', { postAdded: post });
  return post;
}

Auth guards for GraphQL

Standard NestJS guards expect an HTTP request object, but GraphQL hands you a different execution context. Extend AuthGuard and override getRequest to pull the request out of the GraphQL context.

// auth/gql-auth.guard.ts
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;
  }
}

Subscriptions

Subscriptions push data to clients over WebSockets. A @Subscription() resolver returns an async iterator from PubSub, and the mutation above publishes to the matching trigger. Clients connecting via graphql-ws receive every new post in real time.

// posts/posts.resolver.ts (subscription)
import { Subscription } from '@nestjs/graphql';

@Subscription(() => Post, { name: 'postAdded' })
postAdded() {
  return pubSub.asyncIterableIterator('postAdded');
}

Output:

{
  "data": {
    "postAdded": {
      "id": "42",
      "title": "Shipping a GraphQL API with NestJS",
      "author": { "name": "Ada Lovelace" }
    }
  }
}

Operation reference

OperationTypeAuth requiredNotes
postsQueryNoLists all posts
post(id)QueryNoSingle post, nullable
createPostMutationYes (JWT)Validates input, publishes event
postAddedSubscriptionNoStreams newly created posts

Best Practices

  • Prefer the code-first approach so your schema is generated from typed classes and stays in sync automatically.
  • Always scope DataLoaders to the request to batch efficiently without leaking cached data between users.
  • Keep resolvers thin: validation belongs in input DTOs and business logic belongs in services.
  • Use @ResolveField for relations instead of eager joins so clients only pay for the fields they select.
  • Set query depth and complexity limits in production to block expensive, deeply nested queries.
  • Return null from loader batch functions for missing keys so DataLoader can map results back to the correct order.
  • Reuse a single PubSub instance (or a Redis-backed one for multi-instance deployments) across publish and subscribe sites.
Last updated June 14, 2026
Was this helpful?