Building a Modern GraphQL Integration Between Payload CMS and Next.js: Part 1

1 December 2024 by Moritz Thomas

Modern web applications often struggle with balancing competing concerns when integrating a headless CMS with their frontend. This is particularly true for GraphQL integrations, which, while powerful, can present challenges around performance, security, and caching.

In this article, we'll explore a sophisticated approach to integrating Payload CMS with Next.js using GraphQL. Our solution transforms traditional GraphQL POST requests into cacheable GET requests while maintaining excellent developer experience. We'll see how to implement persisted queries, set up efficient caching, and ensure type safety throughout the stack.

The Payload CMS Implementation

The core of our implementation revolves around adding a persisted query system to Payload CMS. While Payload's GraphQL endpoint remains functional (and necessary for our implementation), it should be restricted at the network level to prevent public access while remaining available for internal services when needed.

The Persisted Query Endpoint

The persisted query endpoint is the cornerstone of our implementation. It accepts GET requests and transforms them into GraphQL operations using a pre-registered query map:

export const persisted: Endpoint = {
  path: "/persisted/:operation",
  method: "get",
  handler: async (req, res, next) => {
    try {
      setCacheHeader(req, 60);

      const parseResult = QuerySchema.safeParse(req.query);
      if (!parseResult.success) {
        throw createHttpError.BadRequest(parseResult.error.message);
      }

      const variableValues = secureJson(parseResult.data.variables);
      if (variableValues.limit > MAX_LIMIT || variableValues.depth > MAX_DEPTH) {
        throw createHttpError.BadRequest(
          `Limit (max ${MAX_LIMIT}) and depth (max ${MAX_DEPTH}) are restricted`,
        );
      }

      const source = QUERIES[req.params.operation];
      if (!source) {
        throw createHttpError.NotFound(`Query not found: ${req.params.operation}`);
      }

      const result = await graphql({
        schema: req.payload.schema,
        source,
        contextValue: { req },
        variableValues,
      });

      return res.json(result);
    } catch (error) {
      next(error);
    }
  },
};

This endpoint performs several crucial functions:

  1. Accepts GET requests containing an operation identifier and variables

  2. Sets appropriate cache headers for CDN caching

  3. Validates input against security constraints

  4. Looks up the full GraphQL query using the operation identifier

  5. Executes the query using Payload's GraphQL schema

The Query Map

The persisted query system relies on a pre-registered map of queries. This map is maintained in a TypeScript file that connects query identifiers to their full query strings:

// gql-query-map.ts
export const QUERIES: Record<string, string> = {
  // Added/Updated: 2024-01-15
  "uiStrings_a1b2c3d4": "query uiStrings($locale: LocaleInputType, $where: UiString_where, $limit: Int) { UiStrings(locale: $locale, where: $where, limit: $limit) { docs { id text } } }",
};

export const QUERY_KEYS: Record<string, string> = {
  "uiStrings.graphql": "uiStrings_a1b2c3d4"
};

The query map serves multiple purposes:

  1. Maps friendly file names to unique query identifiers

  2. Maintains version history through query hashes

  3. Enables efficient query lookup during request processing

  4. Provides a clear audit trail with timestamps

Query Development with Apollo Sandbox

While our production system uses persisted queries, during development we want the full power of GraphQL exploration. Apollo Sandbox provides an excellent development experience for this purpose. Setting it up requires minimal configuration: 1. Add a script to package.json:

{
  "scripts": {
    "studio": "start https://sandbox.apollo.dev/?endpoint=http://localhost:3001/api/graphql"
  }
}

2. Enable CORS in Payload for the Apollo Sandbox domain:

export default buildConfig({
  cors: ["https://studio.apollographql.com"],
  // other config...
});

This setup allows us to develop and test queries interactively. For example, we can write and test a query for fetching UI strings:

query uiStrings($locale: LocaleInputType, $where: UiString_where, $limit: Int) {
  UiStrings(locale: $locale, where: $where, limit: $limit) {
    docs {
      id
      text
    }
  }
}

Once we've finalized our queries in Apollo Sandbox, they can be saved as .graphql files and incorporated into our persisted query system.

Comments

No comments yet, be the first:

© 2024 by Moritz Thomas