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

7 December 2024 by Moritz Thomas

Frontend Implementation

With our Payload CMS persisted query system in place, let's explore how to build a type-safe, efficient frontend integration. We'll start by setting up the necessary tooling and then build our way up to a complete frontend implementation.

Type Generation Setup

First, we need to generate TypeScript types from our GraphQL schema. This is handled by GraphQL Code Generator using several plugins:

// package.json
{
  "dependencies": {
    "@graphql-codegen/introspection": "^4.0.3",
    "graphql-codegen-typescript-client": "0.18.2",
    "graphql-codegen-typescript-common": "0.18.2",
    "graphql-codegen-typescript-graphql-files-modules": "0.18.2"
  }
}

The configuration for type generation is defined in codegen.yml:

overwrite: true
schema: "http://localhost:3001/api/graphql"
documents: "**/*.graphql"
generates:
  lib/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-graphql-request"
    config:
      rawRequest: true
      inlineFragmentTypes: inline
      typesPrefix: CMS

This setup ensures that all our GraphQL operations are properly typed. The typesPrefix: CMS configuration helps avoid naming conflicts while making it clear where types originate.

The Fetcher Implementation

At the heart of our frontend implementation is the fetcher system, which handles both development and production environments:

export async function fetcher(identifier: string, variables: any) {
  return IS_PRODUCTION
    ? await persistedFetcher(identifier + ".graphql", variables)
    : (await getSdkLogged()[identifier](variables)).data;
}

This fetcher is smart enough to use different strategies based on the environment:

1. In development, it uses the full GraphQL endpoint with detailed logging:

function getSdkLogged(injectedGqlClient?: GraphQLClient): Sdk {
  const originalSdk = getSdk(injectedGqlClient ?? gqlClient);

  return new Proxy(originalSdk, {
    get(target: any, prop: string | symbol) {
      if (typeof target[prop] === "function") {
        return new Proxy(target[prop], {
          apply: (targetMethod, thisArg, argumentsList) => {
            const operationName = String(prop);
            const startTime = Date.now();

            return targetMethod.apply(thisArg, argumentsList).then((result: any) => {
              const endTime = Date.now();
              const duration = endTime - startTime;

              console.log(
                chalk.cyan("[GraphQL]"),
                chalk.green(operationName.padEnd(20)),
                chalk.yellow(`(${duration}ms)`),
              );

              return result;
            });
          },
        });
      }
      return target[prop];
    },
  });
}

2. In production, it uses our persisted query system:

export async function persistedFetcher<T = any>(
  operationFile: string,
  variables: Record<string, any>,
): Promise<T> {
  const startTime = Date.now();
  const queryKey = getQueryKey(operationFile);

  try {
    const response = await fetch(buildUrl(queryKey, variables));

    if (!response.ok) {
      throw new Error(`GraphQL request failed: ${response.statusText}`);
    }

    const { data, errors } = await response.json();
    if (errors?.[0]) {
      throw new Error(errors[0].message);
    }

    logOperation(queryKey, Date.now() - startTime);
    return data;
  } catch (error) {
    logOperation(
      queryKey,
      Date.now() - startTime,
      error instanceof Error ? error : new Error("Unknown error"),
    );
    throw error;
  }
}

Locale Handling

A common challenge in internationalized applications is handling locale formats. Next.js uses the standard "en-US" format, while GraphQL prefers "en_US". We handle this conversion seamlessly:

export const convertCMSLocale = (
  string?: LocaleType,
): CMSLocaleInputType & CMSComment_Locale_Input =>
  string?.replace("-", "_") as CMSLocaleInputType & CMSComment_Locale_Input;

Building Type-Safe API Functions

With our foundation in place, we can build type-safe functions for fetching data. Here's an example for fetching UI strings:

export async function fetchUIStrings(
  locale: LocaleType,
  ids: string[] = [],
): Promise<Record<string, string>> {
  const data = await fetcher("uiStrings", {
    locale: convertCMSLocale(locale),
    limit: ids.length + 1,
    where: {
      id: {
        in: ids,
      },
    },
  });

  return Object.fromEntries(
    ZodUiStrings.parse(data.UiStrings?.docs).map((doc) => [doc.id, doc.text]),
  );
}

This function showcases several important features:

  1. Type-safe locale conversion

  2. Runtime validation using Zod

  3. Smart limit calculation to catch potential issues early

  4. Clean error handling

Integration with Next.js

Using these functions in Next.js pages becomes straightforward:

export const getStaticProps: GetStaticProps = async ({ locale }) => {
  const validatedLocale = validateLocale(locale);
  
  try {
    const strings = await fetchUIStrings(validatedLocale, [
      "comments-headline",
      "comments-error"
    ]);

    return {
      props: {
        strings
      },
      revalidate: 60
    };
  } catch (error) {
    // Error handling...
  }
};

Error Handling and Logging

Our implementation includes comprehensive error handling and logging:

const logOperation = (operationName: string, duration: number, error?: Error): void => {
  const status = error ? chalk.red : chalk.green;
  const baseLog = [
    chalk.cyan("[Persisted GraphQL]"),
    status(operationName.padEnd(28)),
    chalk.yellow(`(${duration}ms)`),
  ];

  console.log(...baseLog, ...(error ? [chalk.red("ERROR:"), error.message] : []));
};

This logging system provides:

  1. Operation timing information

  2. Color-coded success/failure status

  3. Detailed error messages when things go wrong

  4. Consistent formatting for easy log parsing

In the next part, we'll explore the complete development workflow and advanced developer tooling that makes this system a joy to work with.

Comments

No comments yet, be the first:

© 2024 by Moritz Thomas