Modern full-stack apps often use a JavaScript frontend like Angular and a backend built with Node.js. These layers communicate through APIs, and GraphQL is a popular choice for making those APIs more flexible.
With GraphQL, the Angular client requests exactly the data it needs, while the Node.js server fetches that data from databases or other services. Since the GraphQL schema is shared between frontend and backend, the frontend can evolve independently. The client sends GraphQL queries or mutations to the server, and the server handles them using resolver functions. This approach avoids creating numerous fixed REST endpoints and allows the frontend to retrieve only the specific fields it needs.
Authentication
A common method for securing requests is using JWT (JSON Web Token). After a user logs in, the backend issues a JWT. The Angular app should store this token and include it with each GraphQL request. For example, this can be done using Apollo Angular’s setContext
middleware:
const auth = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return token ? { headers: { Authorization: `Bearer ${token}` } } : {};
});
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });
apollo.create({ link: auth.concat(httpLink), cache: new InMemoryCache() });
This code attaches Authorization: Bearer <token> to each request when a user is logged in. On the Node.js side, Apollo Server’s context function can read and verify this token on every request. For example:
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const token = req.headers.authorization || '';
const user = verifyJwt(token); // your function to verify the JWT
return { user };
}
});
Resolvers can then check context.user. If context.user is missing or invalid, you should reject the request. Best practice is to throw a GraphQLError with a clear code. For instance:
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
Using specific codes like UNAUTHENTICATED or FORBIDDEN helps the Angular client. You can also check context.user for roles or permissions and throw a GraphQLError with code.
CORS (Cross-Origin Resource Sharing)
If the Angular app and the Node server run on different domains or ports, browsers block requests by default. To allow requests, enable CORS on the server. In an Express setup, you can use the CORS middleware:
const cors = require('cors');
app.use(cors()); // Allow all origins (for development only)
This line lets any origin access the API. In production, you should restrict it to your actual front-end domain. For example:
app.use(
'/graphql',
cors({ origin: 'http://localhost:4200', credentials: true }),
express.json(),
expressMiddleware(server)
);
This configuration only lets requests from http://localhost:4200 reach the /graphql endpoint. The credentials: true option means cookies or auth headers are allowed. On the Angular side, if you were using cookies for authentication, you would set withCredentials: true in the HTTP link. However, since we are sending JWTs in headers as shown above, you usually don’t need cookies or withCredentials.
Error Handling in GraphQL
GraphQL is different from REST: it always returns a 200 OK HTTP status, even if there are errors. Errors appear in the response payload. A best practice is to use structured error objects with codes. Apollo Server automatically assigns codes, but you can also throw custom errors.
For example:
const { GraphQLError } = require('graphql');
if (args.value < 0) {
throw new GraphQLError('Value must be non-negative', {
extensions: { code: 'BAD_USER_INPUT', argumentName: 'value' }
});
}
This will produce a response with an error like:
"errors": [
{
"message": "Value must be non-negative",
"extensions": {
"code": "BAD_USER_INPUT",
"argumentName": "value"
}
}
]
Including the extensions.code helps the Angular client know exactly what went wrong, such as showing a validation message. On the client side, you handle GraphQL errors in your Apollo calls.
For example:
this.apollo.watchQuery({ query: GET_DATA }).valueChanges.subscribe({
next: ({ data, errors }) => {
// data contains the result; errors contains any GraphQL errors
},
error: (networkError) => {
console.error('Network error:', networkError);
}
});
In this code, errors come in the next callback, and network or other low-level errors come in the error callback. Using clear error codes like UNAUTHENTICATED for auth issues or BAD_USER_INPUT for bad input helps the UI handle each case properly.
Performance Optimization
One common inefficiency in GraphQL is the “N+1 queries” problem, where a resolver might make a database call for each item in a list. Facebook’s DataLoader library is a popular solution to batch and cache these calls.
For example:
const DataLoader = require('dataloader');
const userLoader = new DataLoader(async (ids) => {
return await UserModel.find({ id: { $in: ids } });
});
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ loaders: { user: userLoader } })
});
In your resolvers, use context.loaders.user.load(userId) instead of querying the database directly. DataLoader will batch multiple .load(…) calls for different IDs into a single find query. This cuts down on redundant database queries and improves performance.
Other performance tips include:
- Response Caching: Apollo Server can cache query results in memory or an external store to speed up repeat queries.
- Automatic Persisted Queries: This feature sends only a query ID instead of the full GraphQL text, reducing network payload.
- Query Complexity Limits: Use libraries like graphql-depth-limit or graphql-cost-analysis to prevent very deep or expensive queries.
- Client-side Caching: Apollo Client’s in-memory cache in Angular keeps fetched data, so components can reuse it without refetching.
Combining batching (with DataLoader), caching, and query limiting makes your app faster and more scalable.
Conclusion
All together, Node.js, Angular, and GraphQL create a clean but powerful full-stack solution. The Node.js GraphQL server is just one endpoint that hides backend complexity. The server can limit access via authentication, CORS policies, and structured error handling. The Angular client, with the Apollo Client, makes queries and updates simple, taking advantage of caching and real-time updating out of the box. Frontend developers write rich queries and mutations without worrying about REST endpoints or over-fetching.
Key Best Practices:
- JWT Authentication: Place the JWT inside the Authorization header on all requests.
- Enable CORS Wisely: Allow only your Angular app’s origin to access the API.
- Structured Errors: Throw GraphQLError with clear extensions, code values.
- Optimized Data Loading: Use DataLoader and Apollo caching to batch requests and cache data.
By following these practices, your code will be more secure, optimized, and maintainable.