Back to Blog

GraphQL vs REST for SaaS APIs — A Decision We Made for a Real Project

Published: 2026-06-23
GraphQL vs REST for SaaS APIs — A Decision We Made for a Real Project

The client's technical co-founder said it in the first call: "We want GraphQL." Not "we are considering GraphQL" or "what do you recommend" — "we want GraphQL." He had read the blog posts, seen the conference talks, and was convinced that REST was legacy technology that would slow down his SaaS dashboard.

We have been on the other side of this conversation enough times to know that the right answer is never "yes, GraphQL is better" or "no, stick with REST." The right answer is "show me the data access patterns." So we spent the next two weeks doing exactly that: mapping every screen in the dashboard, every relationship the frontend needed, every caching requirement, and every integration point. The result was a real GraphQL vs REST decision — not a theoretical one, not a preference-driven one, but a decision backed by actual usage data.

There are over 30,000 SaaS companies worldwide (industry trackers, 2025), and most of them will face this choice at some point. This post is the analysis we did for that project, the implementation we built, and the honest tradeoffs we discovered along the way. If you are trying to make your own GraphQL vs REST decision for a real SaaS project, this is the framework we wish someone had given us.

GraphQL vs REST decision for SaaS API — direction arrows on a street symbolizing a technology choice

The Project Context

The client was building a project management SaaS with a dashboard that needed to display:

  • A list of projects with their status, deadline, and assigned team members
  • Each team member's recent activity across projects
  • Nested task hierarchies with comments, attachments, and timers
  • Real-time updates when tasks changed status
  • Role-based views where different team roles see different data shapes

The dashboard had six main views, each requiring data from multiple related entities. A project overview screen needed the project, its team members, their current tasks, and each task's latest comment — all in one render. A REST API would require five or six round trips for that single view. A GraphQL query could return everything in one request.

This was the use case GraphQL was literally designed for: complex data relationships where the client needs control over the response shape. The client's instinct was correct — their access patterns leaned GraphQL. But there were parts of the system — public API endpoints, file uploads, webhook callbacks — where REST was clearly the better choice. The decision was not "which one for the whole application" but "which one for each concern."

What GraphQL Actually Solves

GraphQL solves two specific problems that REST does not address well:

Over-fetching. A REST /projects endpoint returns every field on the project resource, whether the client needs it or not. The dashboard's project card only needs the name, status, and assignee avatar — but the response includes the description, the created date, the tags array, the attachments array, and every other field. Multiply that by twenty project cards on a page, and you are downloading data the UI never renders.

Under-fetching. That same project card also needs the assignee's name and avatar, which are on a different resource. The REST approach is to fetch /projects, then for each project fetch /users/:id — which means the dashboard makes one call for the list and N calls for the related users. GraphQL returns all of it in one query because the query specifies the shape.

GRAPHQL
1query DashboardProjects {
2  projects {
3    id
4    name
5    status
6    assignee {
7      name
8      avatarUrl
9    }
10  }
11}

One request, twenty project cards, zero over-fetching, zero under-fetching. If your application has data relationships like this — and most SaaS dashboards do — GraphQL eliminates an entire category of performance problems that REST cannot solve without either over-engineering (nested endpoints like /projects/:id/users) or accepting the N+1 cost.

Where REST Still Wins

For everything GraphQL solves, there are things it makes harder. REST is the right choice for:

Public APIs. Third-party developers expect REST. They have HTTP clients, they understand GET/POST/PUT/DELETE, they can test endpoints in a browser. GraphQL requires a client library, a query document, and an understanding of the schema. For public APIs, REST is the default that requires zero explanation.

HTTP caching. REST responses are cacheable at the HTTP level. The browser, a CDN, or a reverse proxy can cache a GET response and serve it without hitting your server. GraphQL responses go through a single POST endpoint, which means caching requires application-level logic — persisted queries, response hashing, or a dedicated cache layer. It is doable, but it is not free.

File uploads. REST handles file uploads natively via multipart/form-data. GraphQL technically supports uploads through the Upload scalar, but it adds complexity: the multipart request must be parsed differently, the schema must define upload mutations explicitly, and most tooling assumes REST for file operations. We kept file uploads as a REST endpoint even in the GraphQL-heavy project.

Simple CRUD operations. If your API is mostly create-read-update-delete on individual resources, REST is simpler to build, easier to document, and cheaper to maintain. GraphQL adds resolver plumbing, type definitions, and schema management that provides no benefit when the client is always fetching the same resource in the same shape. Every SaaS has a layer of simple CRUD — admin panels, bulk imports, configuration endpoints — and REST handles them better.

We covered REST API design principles thoroughly in our REST API mistakes post, including the contract considerations that make REST the right choice for public-facing surfaces. That analysis was part of why we kept REST for specific slices of this project.

The Decision: Why We Chose GraphQL for This Specific Project

After mapping every screen and every data access pattern, the decision emerged clearly:

GraphQL for the dashboard. The six main views all needed nested, relational data in variable shapes. GraphQL eliminated five to six round trips per view, cut mobile data usage by roughly 40%, and let the frontend team iterate without backend changes.

REST for everything else. The public API for third-party integrations stayed REST. File uploads stayed REST. The admin panel (simple CRUD on users, roles, configurations) stayed REST. Webhook callbacks from external services hit REST endpoints.

This hybrid approach is the most common production pattern we see. Postman's State of the API research has consistently found REST used by the large majority of teams and GraphQL by a sizable minority — and the overlap is almost entirely teams using both. You do not have to choose one for the whole system. Choose GraphQL where its strengths matter. Choose REST where simplicity and caching matter.

The Implementation: NestJS With @nestjs/graphql

NestJS has first-class GraphQL support through the @nestjs/graphql package. We used the code-first approach, which generates the GraphQL schema from TypeScript decorators rather than requiring you to write schema files separately:

GraphQL vs REST implementation in NestJS — a developer typing code on a laptop

TypeScript
1// project.model.ts
2import { ObjectType, Field, ID } from '@nestjs/graphql';
3
4@ObjectType()
5export class Project {
6  @Field(() => ID)
7  id: string;
8
9  @Field()
10  name: string;
11
12  @Field()
13  status: string;
14
15  @Field(() => User)
16  assignee: User;
17
18  @Field(() => [Task])
19  tasks: Task[];
20}

The resolver maps queries to data:

TypeScript
1// projects.resolver.ts
2import { Resolver, Query, ResolveField, Parent } from '@nestjs/graphql';
3
4@Resolver(() => Project)
5export class ProjectsResolver {
6  constructor(
7    private readonly projectsService: ProjectsService,
8    private readonly usersService: UsersService,
9    private readonly tasksService: TasksService,
10  ) {}
11
12  @Query(() => [Project])
13  async projects(): Promise<Project[]> {
14    return this.projectsService.findAll();
15  }
16
17  @ResolveField()
18  async assignee(@Parent() project: Project): Promise<User> {
19    return this.usersService.findById(project.assigneeId);
20  }
21
22  @ResolveField()
23  async tasks(@Parent() project: Project): Promise<Task[]> {
24    return this.tasksService.findByProjectId(project.id);
25  }
26}

The module setup is minimal:

TypeScript
1// projects.module.ts
2import { Module } from '@nestjs/common';
3import { GraphQLModule } from '@nestjs/graphql';
4import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
5import { join } from 'path';
6
7@Module({
8  imports: [
9    GraphQLModule.forRoot<ApolloDriverConfig>({
10      driver: ApolloDriver,
11      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
12      playground: true,
13      context: ({ req }) => ({ req }),
14    }),
15  ],
16  providers: [ProjectsResolver, ProjectsService, UsersService, TasksService],
17})
18export class ProjectsModule {}

The code-first approach means the TypeScript types and GraphQL types stay in sync automatically. When you add a field to the Project class, the schema regenerates and the frontend can query it without any manual schema update.

The N+1 Problem and DataLoader

The initial resolver above has a hidden problem: if the dashboard loads thirty projects, the assignee resolver fires thirty individual usersService.findById calls — one for each project. That is the N+1 problem in action, and it is the most common performance trap in GraphQL implementations.

The fix is DataLoader, a batching and caching utility that collects individual requests and executes them as a single batch query:

TypeScript
1// dataloader.service.ts
2import { Injectable, Scope } from '@nestjs/common';
3import DataLoader from 'dataloader';
4
5@Injectable({ scope: Scope.REQUEST })
6export class DataLoaderService {
7  private readonly userLoader = new DataLoader<string, User>(
8    async (ids: string[]) => {
9      const users = await this.usersService.findByIds(ids);
10      return ids.map((id) => users.find((u) => u.id === id));
11    },
12  );
13
14  getUser(id: string): Promise<User> {
15    return this.userLoader.load(id);
16  }
17}

Inject it into the resolver at request scope so each HTTP request gets a fresh DataLoader instance (preventing stale cache across requests):

TypeScript
1@Resolver(() => Project)
2export class ProjectsResolver {
3  constructor(private readonly loader: DataLoaderService) {}
4
5  @ResolveField()
6  async assignee(@Parent() project: Project): Promise<User> {
7    return this.loader.getUser(project.assigneeId);
8  }
9}

With DataLoader, thirty project assignee lookups become one database query with a WHERE id IN (...) clause. The N+1 collapses to 1+1. Every GraphQL resolver that resolves a parent-to-child relationship should route through a DataLoader, not through a direct service call.

Authentication and Authorization in GraphQL

Auth in GraphQL works through the same NestJS guards you use for REST. The GraphQL module passes the Express request into the context, and guards extract tokens from the request headers:

TypeScript
1// auth.guard.ts
2import { Injectable, ExecutionContext } from '@nestjs/common';
3import { GqlExecutionContext } from '@nestjs/graphql';
4
5@Injectable()
6export class GqlAuthGuard {
7  canActivate(context: ExecutionContext): boolean {
8    const ctx = GqlExecutionContext.create(context);
9    const request = ctx.getContext().req;
10    const token = request.headers.authorization?.split(' ')[1];
11    if (!token) return false;
12    // validate token, attach user to context
13    return true;
14  }
15}

Apply it to resolvers:

TypeScript
1@Resolver(() => Project)
2export class ProjectsResolver {
3  @Query(() => [Project])
4  @UseGuards(GqlAuthGuard)
5  async projects(): Promise<Project[]> {
6    return this.projectsService.findAll();
7  }
8}

Field-level authorization — where certain fields are restricted to specific roles — requires a decorator that checks permissions at the field resolver level. We built a @RoleGuard that reads the requested fields from the GraphQL info object and denies access to restricted fields for unauthorized roles. It is more granular than REST endpoint-level auth and correspondingly more complex, but it gives the frontend a single schema that adapts its available fields based on the requesting user's role.

The Things GraphQL Made Harder

An honest GraphQL vs REST decision includes the tradeoffs. Here is what was harder with GraphQL:

Caching. REST responses cache at the URL level. A GET /projects response can be cached by a CDN for sixty seconds, and every subsequent request for that same data hits the cache. GraphQL queries are POST requests with variable bodies, so URL-based caching does not apply. We solved this with persisted queries — mapping query strings to static IDs — and a dedicated Redis cache that keyed on the query hash. It worked, but it took a week to implement what REST gives you for free.

Error handling. REST returns a clear HTTP status code. GraphQL returns 200 for partial successes — some fields resolve, others return errors — and the client has to check the errors array in the response. A query that fetches project data and tasks might succeed for projects but fail for a specific task's nested field. The client must handle partial errors gracefully, which adds complexity to every data-fetching component.

Rate limiting. With REST, you rate-limit by endpoint. A public API can allow 100 requests per minute to /projects and 10 per minute to /projects/import. With GraphQL, every request hits a single endpoint, so rate limiting must be based on query complexity — counting fields, calculating depth, estimating resolver cost. We used graphql-query-complexity to assign costs to fields and reject queries that exceeded the budget.

Monitoring. REST metrics are easy: count requests per endpoint, measure latency per URL. GraphQL metrics require parsing each query to understand what the client actually asked for. A low-volume query for a single project looks the same as a high-volume query for a thousand tasks without parsing the GraphQL document. We added Apollo Tracing and custom metrics that logged resolver-level execution time alongside overall query latency.

We covered API monitoring and metrics more broadly in our event-driven architecture post, which addresses the observability patterns that become critical when your API surface shifts from endpoint-based to query-based.

GraphQL vs REST: The Decision Tree

After the project, we generalised the analysis into a decision tree we now use for every new project:

SituationRecommendation
Complex dashboard with nested relationshipsGraphQL
Public API for third-party developersREST
Mobile app with variable network qualityGraphQL
Simple CRUD with caching requirementsREST
Real-time features (subscriptions)GraphQL
File uploadsREST
Hybrid: internal dashboard + public APIBoth — GraphQL for internal, REST for public
Admin panel with fixed UI shapesREST
Multiple data sources aggregated into one viewGraphQL (federation or stitching)

The rule is not "GraphQL is better" or "REST is better." The rule is: let your data access patterns drive the decision, not the hype cycle. GraphQL for complex, nested, client-shaped queries. REST for simple, cacheable, public-facing operations. Both for most projects worth building.

NestJS GraphQL vs REST implementation — tablet displaying analytics charts on desk representing dashboard data relationships

Conclusion

The client got their GraphQL API. The dashboard loads in a single query instead of six round trips. The frontend team ships features without waiting for backend endpoint changes because they control the query shape. And the parts of the system where REST made more sense — file uploads, public endpoints, webhooks — stayed REST. Nobody on the team wishes we had picked one or the other for everything.

The GraphQL vs REST decision is not settled by which technology is trendier in 2026. It is settled by mapping your actual data access patterns and being honest about which tool fits each pattern. If your application is mostly a dashboard with deep relational queries, GraphQL will save you weeks of N+1 debugging and endpoint proliferation. If your application is mostly a CRUD admin panel with a public API, REST will save you weeks of schema management and caching complexity. And if your application — like most SaaS products — is both, you can run GraphQL on /graphql and REST on /api/v1 in the same NestJS application without any conflict.

We built the GraphQL resolvers described here and the REST endpoints described in our API design patterns post in the same codebase, in the same sprint, with the same team. The framework works because it follows the data, not the fashion. Let your access patterns choose for you.

Frequently Asked Questions

Choose GraphQL when your frontend needs flexible data shapes, you have complex nested data relationships (dashboard with users, projects, tasks, comments), or you need a single endpoint for multiple data sources. Choose REST for simple CRUD APIs, public APIs for third-party developers, or any scenario where HTTP caching is a primary concern.

Use the @nestjs/graphql package with the code-first approach. Define GraphQL object types using @ObjectType() and @Field() decorators, then create resolvers with @Resolver(), @Query(), and @Mutation() decorators. The module setup goes in your AppModule using GraphQLModule.forRoot().

The N+1 problem occurs when a resolver fetches a list of items and then makes one database query per item to resolve nested fields. Solve it with DataLoader (part of graphql-tools), which batches and caches individual requests into single queries. In NestJS, create a DataLoader service and inject it into your resolvers.

GraphQL makes caching harder (no native HTTP caching), complicates file uploads (different from standard multipart forms), requires more complex error handling (partial errors instead of simple status codes), and has a steeper learning curve. Rate limiting is also more complex because you cannot rely on endpoint-based limits.

Use NestJS guards the same way you do with REST — apply @UseGuards() decorators to resolvers and mutations. For field-level authorization, use custom decorators that check roles or permissions. The GraphQL context object in NestJS carries the request, so guards can extract tokens and user data the same way they do in REST endpoints.

Yes. NestJS supports both simultaneously without conflict. Expose GraphQL at /graphql for your complex dashboard queries and keep REST endpoints for simpler operations, public APIs, file uploads, and webhooks. This hybrid approach is common in production and gives you the benefits of both without the tradeoffs of committing entirely to one.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio