DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

GraphQL APIs with Node.js: Build Flexible APIs with Apollo Server

Building APIs is a core part of backend development, and REST has traditionally been the go-to choice for many applications. However, as applications grow more complex, clients often need data from multiple resources without making several separate requests. GraphQL provides an alternative by allowing clients to request exactly the data they need through a single, strongly typed API endpoint.

When combined with Node.js, GraphQL becomes a powerful option for building flexible, maintainable backend services. With libraries such as Apollo Server, developers can define schemas, implement resolvers, validate queries, and connect APIs to databases or external services.

In this guide, we'll explore how GraphQL works, how its core components fit together, and how to build a runnable GraphQL API using Node.js. By the end, you'll understand how to structure a small API and how to extend the same principles to production applications.

Building a GraphQL API with Node.js and Apollo Server

GraphQL is a query language for APIs and a runtime for executing those queries against your data. Instead of exposing multiple resource-specific endpoints, a typical GraphQL server exposes a single endpoint, such as /graphql, where clients describe the data they want to receive. The server validates each query against a schema and uses resolver functions to retrieve the requested information.

Three concepts are central to GraphQL development. The schema defines available types and operations, queries retrieve data, and mutations modify data. Resolvers connect schema fields to actual application logic, whether that logic reads an in-memory collection, queries a database, or calls another service.

For example, an application might expose a User type with an identifier, name, and email address. A client can request only the name and email instead of receiving every field available on the user object. This flexibility can reduce unnecessary data transfer, although performance still depends on resolver implementation, query complexity, and database access patterns.

The following example uses Node.js, ES modules, and Apollo Server. It implements an in-memory user collection, a query for retrieving users, a query for finding one user by ID, and mutations for creating and updating users. The API also demonstrates input validation, consistent error handling, and step-by-step console logging to make the execution flow easier to understand.

To run the example, install a recent supported Node.js version, create a project, and install the dependencies with npm install @apollo/server graphql. Add "type": "module" to your package.json, save the code as server.js, and run node server.js. Open the GraphQL endpoint shown in the terminal in a GraphQL-capable client, such as Apollo Sandbox when available, and execute the sample operations below.

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';

// Step 1: Create a small in-memory data store.
// A database can replace this array in a production application.
const users = [
  { id: '1', name: 'Ansh', email: 'ansh@example.com' },
  { id: '2', name: 'Priya', email: 'priya@example.com' },
];

// Generate unique identifiers for this demonstration.
let nextUserId = 3;

// Step 2: Define the GraphQL schema.
// Types describe available data; queries read and mutations change it.
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
    updateUser(id: ID!, name: String, email: String): User
  }
`;

// Step 3: Implement resolvers for schema operations.
const resolvers = {
  Query: {
    // Return a copy so callers cannot directly modify the array.
    users: () => {
      console.log('[Query] Fetching all users');
      return users.map((user) => ({ ...user }));
    },

    // Find one user by its GraphQL ID argument.
    user: (_, { id }) => {
      console.log(`[Query] Finding user with ID: ${id}`);
      const foundUser = users.find((user) => user.id === id);
      return foundUser ? { ...foundUser } : null;
    },
  },

  Mutation: {
    // Validate input before adding a user to the data store.
    createUser: (_, { name, email }) => {
      console.log('[Mutation] Creating a new user');

      if (!name.trim()) {
        throw new GraphQLError('Name cannot be empty.', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      const normalizedEmail = email.trim().toLowerCase();
      const emailExists = users.some(
        (user) => user.email.toLowerCase() === normalizedEmail,
      );

      if (emailExists) {
        throw new GraphQLError('Email is already registered.', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      const newUser = {
        id: String(nextUserId++),
        name: name.trim(),
        email: normalizedEmail,
      };

      users.push(newUser);
      console.log(`[Mutation] Created user with ID: ${newUser.id}`);
      return { ...newUser };
    },

    // Update only the fields supplied by the client.
    updateUser: (_, { id, name, email }) => {
      console.log(`[Mutation] Updating user with ID: ${id}`);
      const existingUser = users.find((user) => user.id === id);

      if (!existingUser) {
        return null;
      }

      if (name !== undefined) {
        if (!name.trim()) {
          throw new GraphQLError('Name cannot be empty.', {
            extensions: { code: 'BAD_USER_INPUT' },
          });
        }
        existingUser.name = name.trim();
      }

      if (email !== undefined) {
        const normalizedEmail = email.trim().toLowerCase();
        const emailExists = users.some(
          (user) => user.id !== id && user.email === normalizedEmail,
        );

        if (emailExists) {
          throw new GraphQLError('Email is already registered.', {
            extensions: { code: 'BAD_USER_INPUT' },
          });
        }

        existingUser.email = normalizedEmail;
      }

      console.log(`[Mutation] Updated user with ID: ${id}`);
      return { ...existingUser };
    },
  },
};

// Step 4: Create the Apollo GraphQL server.
const server = new ApolloServer({ typeDefs, resolvers });

// Step 5: Start the HTTP server and expose the GraphQL endpoint.
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log('\nGraphQL API is ready!');
console.log(`Endpoint: ${url}`);
console.log('\nTry these operations in your GraphQL client:');

// Query example:
// query {
//   users { id name email }
//   user(id: "1") { name email }
// }

// Mutation example:
// mutation {
//   createUser(name: "Riya", email: "riya@example.com") {
//     id
//     name
//     email
//   }
// }

// Update example:
// mutation {
//   updateUser(id: "1", name: "Ansh Patel") {
//     id
//     name
//     email
//   }
// }

// The server remains active until its process is stopped.
// Press Ctrl+C in the terminal to shut it down.
Enter fullscreen mode Exit fullscreen mode

Conclusion

GraphQL and Node.js make a flexible combination for building APIs that serve web applications, mobile clients, and other consumers. Apollo Server simplifies schema definition, resolver implementation, and HTTP integration, allowing developers to focus on application behavior rather than writing every part of the request-processing infrastructure themselves.

The example demonstrates the basic building blocks, but an in-memory array is suitable only for learning and simple demonstrations. Real applications should use a persistent database, authenticate users, authorize sensitive operations, validate inputs carefully, and enforce query depth, complexity, and rate limits where appropriate. Database-backed resolvers should also consider batching and caching to avoid the N+1 query problem.

Start with a small schema, keep business logic separate from resolvers as the application grows, and add automated tests for both successful operations and error cases. GraphQL is not automatically better than REST for every project, but understanding both approaches helps you choose the right API architecture for each application's requirements.

Top comments (0)