Skip to main content
Back to Blog
GraphQLNode.jsApolloKitQLAPI Design

Building a GraphQL API on Node.js with Apollo or KitQL

A practical, code-first guide to building a production-ready GraphQL API on Node.js using Apollo Server and KitQL, covering schema design, resolvers, and hybrid REST/GraphQL architectures for mixed-client apps.

July 31, 202616 min readNiraj Kumar

Introduction

If you've shipped more than one API in the last few years, you've probably had this conversation with yourself: "Do we really need GraphQL here, or is REST good enough?" The honest answer, in 2026, is usually "both — just not for the same problem."

GraphQL has matured from a Facebook-internal experiment into a battle-tested standard for building flexible, client-driven APIs. Node.js remains the most common runtime for GraphQL servers, and the tooling ecosystem around it — Apollo Server, GraphQL Yoga, Mercurius, and newer entrants like KitQL — has matured alongside it.

This post is a hands-on walkthrough of building a real GraphQL API on Node.js. We'll cover:

  • Core GraphQL concepts you actually need (not the whole spec)
  • Schema design principles that hold up as your API grows
  • Writing clean, testable resolvers
  • Apollo Server vs. KitQL — when to reach for which
  • A hybrid architecture that serves REST and GraphQL from the same backend
  • Best practices, common mistakes, and production-tested pro tips

By the end, you'll have a mental model — and working code — for building a GraphQL layer that plays nicely with existing REST clients instead of forcing a risky big-bang migration.


Why GraphQL, and Why Node.js?

REST APIs organize data around endpoints. GraphQL organizes data around a graph of types, and lets the client decide exactly what shape of data it wants in a single request.

Consider a product page that needs:

  • Product details
  • Reviews (paginated)
  • Related products
  • Current user's wishlist status

In REST, that's often 3–4 round trips (or one bloated, page-specific endpoint). In GraphQL, it's one query:

query ProductPage($id: ID!) {
  product(id: $id) {
    name
    price
    reviews(first: 5) {
      rating
      comment
    }
    relatedProducts {
      name
      price
    }
    isWishlisted
  }
}

Node.js is a natural fit for the server side because:

  • Non-blocking I/O maps well to GraphQL's resolver model, where dozens of fields may need to be resolved concurrently.
  • The JavaScript/TypeScript ecosystem dominates GraphQL tooling — code-first schema libraries, codegen, and client integrations (Apollo Client, urql, Relay) all assume a JS-friendly backend.
  • Full-stack frameworks like SvelteKit, Next.js, and Remix run on Node.js, making it natural to colocate your GraphQL server with your frontend build pipeline — which is exactly the niche KitQL was built for.

Core Concepts You Need Before Writing Code

The Schema Is the Contract

Every GraphQL server is built around a schema written in the GraphQL Schema Definition Language (SDL). It declares every type, field, and operation the API supports — think of it as a strongly typed, self-documenting contract between client and server.

type Book {
  id: ID!
  title: String!
  author: Author!
  publishedYear: Int
}

type Author {
  id: ID!
  name: String!
  books: [Book!]!
}

type Query {
  books: [Book!]!
  book(id: ID!): Book
}

type Mutation {
  addBook(title: String!, authorId: ID!): Book!
}

A few things worth internalizing early:

  • ! means non-nullable. Get this wrong and you'll either break clients with unexpected nulls, or throw runtime errors that are hard to trace.
  • Query, Mutation, and Subscription are just regular object types with a special role — the entry points into your graph.
  • Types can reference each other (Book.authorAuthor), which is what makes it a graph rather than a flat list of endpoints.

Resolvers Turn Schema Into Behavior

A schema alone does nothing — resolvers are the functions that fetch or compute the actual data for each field.

const resolvers = {
  Query: {
    books: (_, __, context) => context.dataSources.books.getAll(),
    book: (_, { id }, context) => context.dataSources.books.getById(id),
  },
  Book: {
    author: (book, _, context) => context.dataSources.authors.getById(book.authorId),
  },
};

Notice that Book.author has its own resolver. GraphQL resolves each field independently and recursively, which is powerful — but it's also the root of the most common GraphQL performance mistake, the N+1 query problem, which we'll fix later with DataLoader.

Operations: Query, Mutation, Subscription

  • Query — read operations, side-effect free
  • Mutation — writes, executed serially (unlike queries, which can run in parallel)
  • Subscription — long-lived, real-time operations over WebSockets or SSE

Schema Design: Getting It Right Early

Schema design mistakes are expensive to fix later because clients depend directly on field names and types. A few principles that consistently pay off:

1. Design Around Client Use Cases, Not Database Tables

It's tempting to mirror your SQL schema 1:1 into GraphQL types. Resist it. Your users table might have 40 columns; your User GraphQL type should expose what clients actually need, grouped sensibly:

type User {
  id: ID!
  displayName: String!
  avatarUrl: String
  profile: UserProfile!
  preferences: UserPreferences!
}

This decoupling lets you refactor your database freely without breaking the public contract.

2. Use Nullability Deliberately

A common anti-pattern is marking everything non-nullable "to be safe." In distributed systems, almost anything can fail — a downstream service timing out shouldn't take down an entire query. Prefer nullable fields for anything that depends on an external call, and let the client handle partial data gracefully.

3. Model Relationships as Connections for Paginated Lists

For any list that can grow unbounded (reviews, comments, orders), use the Relay-style connection pattern rather than a plain array:

type ReviewConnection {
  edges: [ReviewEdge!]!
  pageInfo: PageInfo!
}

type ReviewEdge {
  node: Review!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

It's more boilerplate up front, but it standardizes pagination across your whole API and avoids painful breaking changes later (going from reviews: [Review!]! to a paginated version is a breaking change).

4. Version Through Evolution, Not URLs

REST APIs often version via /v1/, /v2/. GraphQL schemas evolve additively instead: add new fields, deprecate old ones with @deprecated(reason: "..."), and remove them only after clients have migrated. Tools like Apollo's schema checks or GraphQL Inspector can flag breaking changes in CI before they ship.

type Product {
  price: Float @deprecated(reason: "Use priceV2 with currency support")
  priceV2: Money!
}

Building the API with Apollo Server

Apollo Server is the most widely adopted GraphQL server for Node.js, with strong support for federation, caching, and a mature plugin ecosystem. Here's a minimal but production-shaped setup using Apollo Server 4 with Express.

npm install @apollo/server graphql express cors body-parser
// server.js
import express from "express";
import cors from "cors";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { typeDefs } from "./schema.js";
import { resolvers } from "./resolvers.js";
import { createContext } from "./context.js";

const app = express();

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

await server.start();

app.use(
  "/graphql",
  cors(),
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }) => createContext(req),
  })
);

app.listen(4000, () => {
  console.log("🚀 GraphQL server ready at http://localhost:4000/graphql");
});

The context function runs on every request and is the right place to attach authenticated user info, request-scoped DataLoaders, and database connections:

// context.js
import { DataLoader } from "dataloader";
import { getUserFromToken } from "./auth.js";
import { db } from "./db.js";

export async function createContext(req) {
  const user = await getUserFromToken(req.headers.authorization);

  return {
    user,
    db,
    loaders: {
      authorById: new DataLoader(async (ids) => {
        const authors = await db.author.findMany({ where: { id: { in: ids } } });
        const map = new Map(authors.map((a) => [a.id, a]));
        return ids.map((id) => map.get(id) ?? null);
      }),
    },
  };
}

That DataLoader is what solves the N+1 problem — instead of firing one query per book to fetch its author, it batches all requested author IDs from a single tick of the event loop into one query.

Resolvers, Cleanly Separated

// resolvers.js
export const resolvers = {
  Query: {
    books: (_, __, { db }) => db.book.findMany(),
    book: (_, { id }, { db }) => db.book.findUnique({ where: { id } }),
  },
  Mutation: {
    addBook: async (_, { title, authorId }, { db, user }) => {
      if (!user) throw new Error("UNAUTHENTICATED");
      return db.book.create({ data: { title, authorId } });
    },
  },
  Book: {
    author: (book, _, { loaders }) => loaders.authorById.load(book.authorId),
  },
};

Keep resolvers thin. Their job is to orchestrate, not to contain business logic — push validation, authorization rules, and complex computation into dedicated service modules that resolvers simply call into. This keeps resolvers testable and your business logic reusable outside of GraphQL (e.g., in background jobs or REST endpoints, as we'll see shortly).


Building the API with KitQL

KitQL takes a different philosophy: instead of a standalone GraphQL server, it's a toolkit built for full-stack, colocated GraphQL — most commonly inside SvelteKit apps, though its code-generation and client tooling are framework-agnostic. If Apollo is "run a dedicated GraphQL service," KitQL is closer to "GraphQL as a first-class citizen inside your existing full-stack app."

A typical KitQL setup wires a GraphQL Yoga (or similar spec-compliant) server directly into your SvelteKit server hooks, and generates typed operations from .graphql files at build time:

npm install @kitql/graphql-codegen @kitql/client graphql-yoga
// src/hooks.server.js
import { createYoga } from "graphql-yoga";
import { schema } from "./graphql/schema";

const yoga = createYoga({
  schema,
  graphqlEndpoint: "/api/graphql",
});

export async function handle({ event, resolve }) {
  if (event.url.pathname.startsWith("/api/graphql")) {
    return yoga.fetch(event.request, event);
  }
  return resolve(event);
}

KitQL's codegen then reads your .graphql query/mutation files and generates fully typed client functions — so a query written once gives you end-to-end TypeScript types from server schema to client component, with zero manual type maintenance.

# src/lib/queries/GetBook.graphql
query GetBook($id: ID!) {
  book(id: $id) {
    title
    author {
      name
    }
  }
}
<script>
  import { GetBookStore } from "$houdini"; // or KitQL-generated equivalent
  export let data;
</script>

<h1>{$GetBookStore.data?.book.title}</h1>

When KitQL makes sense:

  • You're already building on SvelteKit (or a similarly integrated meta-framework) and want GraphQL without standing up a separate service.
  • You want tight, generated typing between schema and UI with minimal manual wiring.
  • Your team is small and values convention-over-configuration over Apollo's more extensive (and more complex) plugin ecosystem.

When Apollo makes more sense:

  • You need federation — splitting your graph across multiple services/teams (Apollo Federation is the de facto standard here).
  • You're serving many different clients (web, mobile, third-party partners) from one standalone GraphQL service.
  • You need Apollo's mature caching, tracing, and persisted-queries tooling for a large-scale public or partner-facing API.

Neither is objectively "better" — they solve different shaped problems. Apollo is a service; KitQL is a toolkit for full-stack apps.


The Hybrid Approach: REST and GraphQL, Together

Here's the part most tutorials skip: you rarely get to greenfield your way into pure GraphQL. You have existing REST clients — a mobile app pinned to an old API version, a partner integration, a webhook consumer — that you can't or shouldn't force onto GraphQL overnight.

The good news: nothing about GraphQL requires you to delete your REST routes. You can run both from the same Node.js process, sharing the same service layer underneath.

Architecture: Shared Service Layer, Two Interfaces

┌─────────────┐     ┌─────────────┐
│  REST routes │     │ GraphQL resolvers │
└──────┬──────┘     └──────┬──────┘
       │                   │
       └─────────┬─────────┘
                  ▼
          Service / Domain Layer
                  │
                  ▼
              Database

The key move is refactoring business logic out of both your Express route handlers and your resolvers, into a shared service module:

// services/bookService.js
export async function getBookById(db, id) {
  const book = await db.book.findUnique({ where: { id } });
  if (!book) throw new NotFoundError("Book not found");
  return book;
}

export async function createBook(db, { title, authorId }, user) {
  requirePermission(user, "book:write");
  return db.book.create({ data: { title, authorId } });
}

Both interfaces call the same function:

// REST route
app.get("/api/books/:id", async (req, res) => {
  const book = await getBookById(db, req.params.id);
  res.json(book);
});
// GraphQL resolver
const resolvers = {
  Query: {
    book: (_, { id }, { db }) => getBookById(db, id),
  },
};

This gives you:

  • One source of truth for business rules, validation, and authorization
  • REST for simple, cacheable, or legacy-client needs (webhooks, health checks, third-party integrations that expect plain JSON over predictable URLs)
  • GraphQL for rich, client-driven views (dashboards, mobile apps that need flexible field selection, anything with deeply nested relationships)

Practical Patterns for Hybrid APIs

1. REST as a thin wrapper, GraphQL as the primary API. A common pattern for public APIs: build GraphQL first, then auto-generate or hand-write a handful of REST endpoints for the operations that external partners specifically need — often just the most common reads.

2. Use API Gateway routing at the edge. Route /graphql and /rest/* (or /v1/*) through the same gateway (nginx, an AWS API Gateway, or a lightweight Node reverse proxy) so clients don't need to know two different hostnames.

3. Share authentication middleware. Whether a request comes in as REST or GraphQL, it should pass through the same auth middleware before reaching either interface, so you don't maintain two divergent security models.

app.use(authenticate); // shared for both /api/* and /graphql

4. Consider GraphQL as an internal BFF (Backend-for-Frontend) in front of REST. Sometimes the pragmatic move is the reverse: keep your core services as REST/gRPC internally, and put a GraphQL layer in front purely as an aggregation and shaping layer for frontend clients. Resolvers simply call out to internal REST services:

const resolvers = {
  Query: {
    product: async (_, { id }, { restClient }) => {
      const [product, reviews] = await Promise.all([
        restClient.get(`/products/${id}`),
        restClient.get(`/products/${id}/reviews`),
      ]);
      return { ...product, reviews };
    },
  },
};

This "GraphQL as BFF" pattern is extremely common in 2026-era architectures — it lets you adopt GraphQL for the frontend experience without touching a large, stable REST backend at all.


Best Practices

  • Batch and cache with DataLoader. Any resolver that fetches related entities by ID should go through a DataLoader, scoped per-request via context.
  • Set query complexity and depth limits. An open GraphQL endpoint without limits is an easy target for deeply nested, resource-exhausting queries. Libraries like graphql-query-complexity or Apollo's built-in cost analysis plugins are essential for public APIs.
  • Use persisted queries in production. Instead of accepting arbitrary query strings from clients, register known queries ahead of time and have clients send a hash. This shrinks payloads and closes off unexpected, expensive ad-hoc queries.
  • Automate schema linting in CI. Tools like GraphQL Inspector catch breaking changes (removed fields, changed types) before they reach production.
  • Log and monitor per-field resolver performance. Apollo's tracing extensions (or OpenTelemetry integrations) will show you exactly which field is slow — invaluable once your schema has hundreds of fields.
  • Keep error handling consistent. Use a structured error format (extensions.code) rather than leaking raw stack traces, and map domain errors (NotFoundError, PermissionError) to consistent GraphQL error codes.
  • Document with schema descriptions. SDL supports triple-quoted descriptions on every type and field — use them. They show up automatically in GraphQL Playground/Apollo Sandbox and save your team from tribal knowledge.
"""
Represents a published book in the catalog.
"""
type Book {
  "Unique identifier for the book"
  id: ID!
  title: String!
}

Common Mistakes to Avoid

  • Mirroring the database schema directly into GraphQL types. This locks your public API to internal implementation details and makes refactoring painful.
  • Ignoring the N+1 problem until it's a production incident. If you have a resolver that fetches a relation without batching, it will show up as a performance problem once traffic scales.
  • Over-nesting mutations. Keep mutation inputs flat and purposeful (updateBookTitle(id, title)) rather than one giant updateBook(input: BookInput) that silently accepts 30 optional fields with unclear semantics.
  • Skipping authorization at the field level. Authenticating the request isn't enough — some fields (e.g., a user's email or internal notes) need field-level authorization checks inside their resolvers.
  • Treating GraphQL as a silver bullet. Simple CRUD services, webhooks, and file uploads are often genuinely simpler as REST. Don't force everything through GraphQL just because it's there.
  • No query cost limits on a public-facing schema. This is the GraphQL equivalent of leaving a REST API with no rate limiting — it's an availability risk waiting to happen.
  • Forgetting to version your schema evolution strategy. Removing a field without a deprecation window will break clients you don't even know exist yet (mobile apps in the wild can't be force-updated instantly).

🚀 Pro Tips

  • Start with the schema, not the resolvers. Draft your SDL as if the backend already existed, get client-side (frontend/mobile) feedback on the shape, then implement resolvers. Schema-first design surfaces awkward API shapes before they're expensive to change.
  • Use graphql-codegen regardless of Apollo or KitQL. Generating TypeScript types from your schema keeps resolvers and client code in sync automatically and eliminates an entire category of "field doesn't exist" runtime bugs.
  • Run Apollo Sandbox (or GraphQL Yoga's built-in explorer) in every environment, not just locally — it dramatically speeds up debugging for both backend and frontend teams.
  • Colocate fragments with the components that use them on the client side (Apollo Client and Relay both support this) — it keeps data requirements close to where they're consumed and avoids over-fetching creep.
  • When hybridizing REST and GraphQL, expose an internal-only introspection route so your API gateway and monitoring tools can detect schema drift automatically.
  • Use Apollo Federation (or a similar gateway) once you have more than 2–3 teams owning parts of the graph — trying to maintain one monolithic schema across many teams becomes a bottleneck fast.

📌 Key Takeaways

  • GraphQL organizes your API around a strongly typed graph of client-driven fields, reducing over-fetching and under-fetching compared to fixed REST endpoints.
  • A well-designed schema is a durable contract — design it around what clients need, use nullability deliberately, and evolve it additively rather than through versioned URLs.
  • Resolvers should stay thin orchestration layers; real business logic belongs in a shared service layer usable by both GraphQL and REST.
  • Apollo Server is the go-to choice for standalone, federation-ready GraphQL services, while KitQL fits naturally into full-stack, colocated apps like SvelteKit.
  • You don't have to fully commit to one paradigm: a shared service layer lets REST and GraphQL coexist in the same Node.js backend, each serving the clients they're best suited for.
  • Production readiness means DataLoader batching, query complexity limits, persisted queries, and CI-enforced schema linting — not just "it returns the right JSON."

Conclusion

GraphQL isn't a replacement for REST — it's another tool for a specific job: giving clients precise, flexible control over the data they fetch, especially when those clients have very different needs (a mobile app, a dashboard, a partner integration). Node.js remains an excellent home for this, whether you reach for Apollo Server's mature, federation-ready ecosystem or KitQL's lightweight, full-stack-native approach.

The real unlock, though, isn't picking the "right" library — it's recognizing that REST and GraphQL don't have to be in competition inside your architecture. By pushing business logic into a shared service layer and treating REST and GraphQL as two interfaces onto the same domain, you get the best of both: stability and simplicity where you need it, and flexibility and efficiency where it counts.

Start with your schema. Keep your resolvers thin. Share your service layer. And let each client talk to your API the way that suits it best.


References

All Articles
GraphQLNode.jsApolloKitQLAPI Design

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.