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 theQUERYHTTP method for structured, read-only filters. GraphQL normally uses an operation document sent to a GraphQL endpoint, commonly in an HTTPPOSTbody. 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:
| Failure | Example | Where to handle it |
|---|---|---|
| Network/HTTP error | Offline device, CORS refusal, HTTP 500 | RxJS catchError or an HTTP interceptor |
| GraphQL error | Invalid field, permission denied, one field resolver failed | response.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:
| Need | Plain HttpClient | Apollo Angular or similar client |
|---|---|---|
| Send query/mutation | Straightforward | Straightforward |
| Normalized entity cache | Build it yourself | Built in |
| Refetch and fetch policies | Build it yourself | Built in |
| Cursor pagination merge | Build it yourself | Built in helpers/policies |
| Optimistic mutation UI | Manual state update | Built-in patterns |
| Subscriptions | Implement protocol/lifecycle | Link support |
| Devtools and generated services | Separate tooling | Mature 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
- Explore the server schema in GraphiQL or another approved API explorer.
- Write one named query with variables and consume it with
HttpClient.post(). - Model the response envelope and render loading, network-error, GraphQL-error, and data states.
- Add one mutation and update or refetch the affected UI.
- Centralize authentication, common headers, and observability in interceptors.
- Add cursor pagination and
switchMap-based search. - Use fragments and code generation as the operation count grows.
- 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
errorsin 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.