Skip to main content

GraphQL in Angular: consume APIs from zero to hero

You do not need Apollo—or any other GraphQL client—to use GraphQL in Angular. A GraphQL operation is usually an HTTP request with a JSON body, and Angular's built-in HttpClient can send it very well.

This guide begins with that simple fact. It then builds a reusable Angular service, handles the two kinds of errors, adds variables, authentication, pagination, and live updates, and finally explains when a library such as Apollo Angular earns its complexity.

Do not confuse GraphQL with HTTP QUERY. Office Orbit uses the QUERY HTTP method for structured, read-only filters. GraphQL normally uses an operation document sent to a GraphQL endpoint, commonly in an HTTP POST body. They solve different problems and can coexist.

1. The GraphQL request in one picture​

Most GraphQL servers expose one endpoint, such as /graphql. The client sends three values:

POST /graphql
query the GraphQL operation text
variables the changing input values
operationName the selected operation, when named

For example, this asks for only three fields of a user:

query UserById($id: ID!) {
user(id: $id) {
id
name
email
}
}

The variables are sent separately:

{ "id": "42" }

The server validates the operation against its schema, runs the requested fields, and returns a JSON envelope:

{
"data": {
"user": { "id": "42", "name": "Asha", "email": "asha@example.com" }
}
}

The field selection is the core idea. Your Angular screen asks for its data shape instead of accepting one fixed representation from a REST endpoint.

2. Enable Angular HTTP​

Standalone Angular applications register HttpClient once at application startup:

// app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';

export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()],
};

For an NgModule application, import HttpClientModule in the root module instead.

Set the endpoint in Angular's environment configuration rather than repeating it throughout the application:

// environment.ts
export const environment = {
graphqlEndpoint: 'http://localhost:4000/graphql',
};

3. Make your first request with only HttpClient​

Start with small TypeScript types. GraphqlEnvelope represents the outer response shape; GraphqlError represents errors reported by the GraphQL server.

// graphql.models.ts
export interface GraphqlError {
message: string;
path?: Array<string | number>;
extensions?: Record<string, unknown>;
}

export interface GraphqlEnvelope<TData> {
data?: TData;
errors?: GraphqlError[];
}

export interface User {
id: string;
name: string;
email: string;
}

Create a small service. Keeping HTTP work out of components makes it easier to test, reuse, and later replace with a richer client if needed.

// user-api.service.ts
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../environments/environment';
import { GraphqlEnvelope, User } from './graphql.models';

const USER_BY_ID = `
query UserById($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;

interface UserByIdData {
user: User | null;
}

@Injectable({ providedIn: 'root' })
export class UserApiService {
private readonly http = inject(HttpClient);

getUser(id: string): Observable<GraphqlEnvelope<UserByIdData>> {
return this.http.post<GraphqlEnvelope<UserByIdData>>(
environment.graphqlEndpoint,
{
query: USER_BY_ID,
variables: { id },
operationName: 'UserById',
},
{
headers: new HttpHeaders({
'Content-Type': 'application/json',
Accept: 'application/graphql-response+json, application/json',
}),
},
);
}
}

This is the same transport pattern used by the Angular GraphQL Lab: HttpClient.post() sends the operation, variables, and operation name; a client library is optional.

If the Angular application and API run on different origins during development, the GraphQL server must allow the application's origin through CORS and accept POST, Content-Type, and any authentication header you send. This is server configuration; Angular cannot bypass it.

4. Display the result in a component​

An async pipe handles the subscription lifecycle for a simple read:

import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { UserApiService } from './user-api.service';

@Component({
selector: 'app-user-card',
imports: [AsyncPipe],
template: `
@if (result$ | async; as result) {
@if (result.data?.user; as user) {
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
}

@if (result.errors?.length) {
<p role="alert">{{ result.errors![0].message }}</p>
}
}
`,
})
export class UserCardComponent {
private readonly users = inject(UserApiService);
readonly result$ = this.users.getUser('42');
}

In a production UI, also show a loading state before the first value arrives and an error state for network failures, which leads to an important distinction.

5. Handle network errors and GraphQL errors separately​

There are two independent failure layers:

FailureExampleWhere to handle it
Network/HTTP errorOffline device, CORS refusal, HTTP 500RxJS catchError or an HTTP interceptor
GraphQL errorInvalid field, permission denied, one field resolver failedresponse.errors in a successful HTTP response

GraphQL may return partial data and errors together, so do not throw away useful data merely because errors exists. Handle the envelope deliberately:

import { HttpErrorResponse } from '@angular/common/http';
import { catchError, map, throwError } from 'rxjs';

getUserName(id: string) {
return this.getUser(id).pipe(
map((response) => {
if (response.errors?.length) {
throw new Error(response.errors.map((error) => error.message).join(', '));
}
if (!response.data?.user) throw new Error('User was not found.');
return response.data.user.name;
}),
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse) {
return throwError(() => new Error(`Network request failed (${error.status}).`));
}
return throwError(() => error);
}),
);
}

Whether partial data should be rendered is a product decision. A dashboard might render what succeeded and show a warning; a payment screen may need to stop on any error.

6. Reuse operation documents and variables​

Operation strings are easier to maintain when they are named constants or separate .graphql files. Always use variables for values that change, such as text entered by a user.

export const TASKS_QUERY = `
query Tasks($first: Int!, $after: String, $status: TaskStatus, $search: String) {
tasks(first: $first, after: $after, status: $status, search: $search) {
edges {
cursor
node { id title status }
}
pageInfo { endCursor hasNextPage }
totalCount
}
}
`;
this.http.post(environment.graphqlEndpoint, {
query: TASKS_QUERY,
variables: {
first: 20,
after: null,
status: 'TODO',
search: 'graphql',
},
operationName: 'Tasks',
});

Variables avoid fragile string construction and let the server validate values against the schema. The Angular GraphQL Lab uses this same cursor-based tasks operation.

7. Create and update data with mutations​

Mutations look like queries but intentionally change data. Send them through the same endpoint and same HttpClient.post() transport.

const CREATE_TASK = `
mutation CreateTask($input: CreateTaskInput!) {
createTask(input: $input) {
id
title
status
}
}
`;

createTask(input: { title: string; projectId: string }) {
return this.http.post(environment.graphqlEndpoint, {
query: CREATE_TASK,
variables: { input },
operationName: 'CreateTask',
});
}

Request the fields your UI needs after the mutation. For example, asking for the new task's id, title, and status avoids guessing what the server stored.

8. Add authentication with an interceptor​

Do not copy authorization code into every GraphQL service. A functional interceptor can add a token to requests going to your GraphQL endpoint:

// graphql-auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { environment } from '../environments/environment';

export const graphqlAuthInterceptor: HttpInterceptorFn = (request, next) => {
const token = localStorage.getItem('access-token');

if (request.url !== environment.graphqlEndpoint || !token) {
return next(request);
}

return next(request.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}));
};

Register it with the HTTP client:

import { provideHttpClient, withInterceptors } from '@angular/common/http';

providers: [provideHttpClient(withInterceptors([graphqlAuthInterceptor]))]

This only sends a token; the server must still authenticate it and authorize every protected query and mutation. Never treat a hidden field in the client selection as access control.

9. Pagination and search without races​

Cursor pagination is common in GraphQL. Store the server's endCursor, then pass it as after for the next page. Keep the response shape close to the schema:

interface TaskPage {
tasks: {
edges: Array<{ cursor: string; node: { id: string; title: string; status: string } }>;
pageInfo: { endCursor: string | null; hasNextPage: boolean };
};
}

For a type-ahead search, cancel the old request when the text changes. switchMap ensures a slow response for an earlier search cannot overwrite a newer result:

import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

readonly tasks$ = this.searchTerms.pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap((search) => this.taskApi.list({ first: 20, after: null, search })),
);

For cacheable, shared reads without a library, a service can retain the observable with shareReplay. Be deliberate about invalidation after mutations—manual caching is where a dedicated GraphQL client often becomes useful.

10. Fragments, directives, and generated types​

Fragments prevent repeated field selections:

fragment TaskCard on Task {
id
title
status
}

query Tasks($first: Int!) {
tasks(first: $first) {
edges { node { ...TaskCard } }
}
}

Directives can include optional fields without creating two documents:

query Task($id: ID!, $withDescription: Boolean!) {
task(id: $id) {
id
title
description @include(if: $withDescription)
}
}

For a growing application, GraphQL Code Generator can create TypeScript types from the schema and your .graphql files. This removes duplicated hand-written response types and catches a field mismatch before the app runs. It is optional, but becomes valuable when many operations are shared across a team.

11. Subscriptions: live updates​

A subscription lets the server push events such as taskCreated. The operation looks familiar:

subscription OnTaskCreated {
taskCreated { id title status }
}

The transport is different from a one-time HTTP request. It may use WebSocket or GraphQL-over-SSE, and needs reconnect, authentication, cleanup, and error handling. If your server supports an EventSource-friendly SSE endpoint and cookie authentication, native browser EventSource may be enough. For authenticated or WebSocket subscriptions, use a well-tested protocol client rather than inventing the protocol in an Angular component.

Start with polling or refetching unless the product truly needs live data. Subscriptions add operational complexity; they are not required for ordinary GraphQL queries and mutations.

12. When to add Apollo Angular or another client​

The built-in approach is excellent when you have a modest number of operations, simple caching, and want every HTTP detail visible. A dedicated client is worthwhile when you need several of these consistently:

NeedPlain HttpClientApollo Angular or similar client
Send query/mutationStraightforwardStraightforward
Normalized entity cacheBuild it yourselfBuilt in
Refetch and fetch policiesBuild it yourselfBuilt in
Cursor pagination mergeBuild it yourselfBuilt in helpers/policies
Optimistic mutation UIManual state updateBuilt-in patterns
SubscriptionsImplement protocol/lifecycleLink support
Devtools and generated servicesSeparate toolingMature ecosystem

Apollo is not "more GraphQL"—it is client-side infrastructure. The Angular GraphQL Lab uses HttpClient first, then Apollo only for observable queries, normalized caching, fetch policies, pagination, optimistic updates, and subscription routing.

13. A safe progression from beginner to advanced​

  1. Explore the server schema in GraphiQL or another approved API explorer.
  2. Write one named query with variables and consume it with HttpClient.post().
  3. Model the response envelope and render loading, network-error, GraphQL-error, and data states.
  4. Add one mutation and update or refetch the affected UI.
  5. Centralize authentication, common headers, and observability in interceptors.
  6. Add cursor pagination and switchMap-based search.
  7. Use fragments and code generation as the operation count grows.
  8. Add a GraphQL client library only when its cache, subscriptions, or pagination features pay for their learning and maintenance cost.

Final checklist​

  • Request only fields the screen needs.
  • Name operations and send variables separately from the document.
  • Treat HTTP errors and errors in the GraphQL envelope as separate cases.
  • Keep operations and HTTP calls in services, not templates or components.
  • Protect secrets, authenticate at the server, and authorize every resolver.
  • Paginate lists and avoid assuming a nested GraphQL query is automatically efficient.
  • Start with Angular's built-in HttpClient; add third-party infrastructure intentionally.

With these foundations, GraphQL in Angular is simply a well-typed HTTP workflow first—and can grow into a cached, real-time data layer only when your application actually needs it.