Next.JS Fullstack GraphQL
Implementing fullstack GraphQL web app using Next.JS, GraphQL Yoga and Apollo Client

Next.JS is a React framework commonly known for frontend applications. But Next.JS actually is a full stack framework capable of implementing both frontend and backend layer through the use of its api routes based on Restful architecture. Aside from implementing it on REST, you can actually create a GraphQL API that can be consumed on the frontend layer. This blog will focus on the implementation of Next.JS fullstack graphQL using GraphQL Yoga, Type GraphQL, Apollo Client and Codegen where the user requests cryptographic operations to be performed by the backend layer of the app.
Installation of the required packages
To start with, let's create new Next.JS project using create-next-app followed by the procedures from my other blog NextJS Starter Pack or you can just clone the initial repo of this project and installing dependencies.
git clone -b initial-repo https://github.com/Acetylcholine007/next-crypto.git cd next-crypto yarn install
After setting up our starter project, we will now install the important packages to implement and consume GraphQL endpoints.
yarn add graphql@15.8.0 graphql-yoga type-graphql class-validator reflect-metadata typedi @apollo/client
Update your tsconfig.json to merge all the entries given below. This is important for type-graphql to work properly. Optional path entries were also added in the code snippet below to shorten the import address of any files inside the respective folders below to be created later.
{ "compilerOptions": { "target": "es2018", "module": "commonjs", "lib": ["es2018", "esnext.asynciterable"], "experimentalDecorators": true, "emitDecoratorMetadata": true, "paths": { "@__generated__/*": ["./src/__generated__/*"], "@graphql/*": ["./src/graphql/*"], "@enums/*": ["./src/lib/enums/*"], "@models/*": ["./src/lib/models/*"], "@utils/*": ["./src/lib/utils/*"] } } }
Update your next.config.js to allow topLevelAwait
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, webpack: (config) => { config.experiments = { ...config.experiments, topLevelAwait: true }; return config; }, }; module.exports = nextConfig;
First package is the most important as it serves as the core package for building and consuming GraphQL API. Latest version is 16, unfortunately type-graphql only supports 15 at most as of this writing so we have to install version 15. GraphQL Yoga will be used by the backend layer for creating GraphQL server while @apollo/client will facilitate the consumption of the API on frontend layer. Type GraphQL allows us to implement GraphQL endpoints using code-first approach to the implementation. class-validator and reflect-metadata are dependencies of type-graphql with the former being essential for adding validator decorators to the inputs which we will see later. Typedi is also a package to be used by Type GraphQL use for dependency injection. Dependency injection is optional when creating GraphQL infrastructure using type-graphql, nevertheless this project will use it alongside with type-graphql. Another helpful library for the frontend layer would be the graphql-codegen which we'll install next.
yarn add -D @graphql-codegen/add @graphql-codegen/cli @graphql-codegen/typescript-operations @graphql-codegen/typescript @graphql-codegen/typescript-react-apollo
After installation, add a new script inside your package.json
"scripts": { ... "codegen": "graphql-codegen --config codegen.yml" }
Create codegen.yml on the root level of your codebase and paste the following:
overwrite: true schema: './schema.gql' documents: './src/graphql/queries/*.ts' generates: src/__generated__/graphql.ts: config: reactApolloVersion: 3 withHooks: true namingConvention: 'keep' plugins: - add: content: '// THIS IS A GENERATED FILE, use `yarn codegen` to regenerate' - add: content: '/* tslint:disable */' - 'typescript' - 'typescript-operations' - 'typescript-react-apollo'
GraphQL codegen automates creation of custom-made hooks instead of the basic useQuery and useMutation of apollo client by reading the generated graphQL schema and queries we've written, giving us type safety when using a custom hook for a particular graphql query operation.
Implementing the Backend layer
For this project, we will implement an interface allowing the user to perform cryptographic operations to be executed by the backend layer and deliver back the results to the user. For more info about cryptography check my blog Cryptography 101.
We will have the following functionalities:
- Basic Hashing and HMAC
- Symmetric and IV generation
- RSA and EC key pair generation
- Symmetric Encryption and Decryption
- Asymmetric Encryption and Decryption
- Signing and Verifying
First three items will be implemented using query while last three will be through mutation. By concept, any fetching operations like GET method in RESTful has to be implemented using query type in GraphQL while mutating operations like POST, PATCH, PUT and DELETE in RESTful will be implemented using mutation type. In actual usage, it can be interchanged but the key difference of query and mutation is that query can be executed in parallel by the GraphQL server while mutation types will be executed one at a time by GraphQL server to prevent any race conditions. For our case, we don't have persistent data storage to be mutated but the last 3 items can be thought of as a mutation. Moreover, will stick to the plan to demonstrate syntactic difference of query and mutation implementation using the packages we will be using.
Create graphql folder inside your src folder and create subfolders inside with the following names:
- middlewares
- queries
- resolvers
- services
- types
Inside the middlewares folder, create a file error.interceptor.ts and paste the code below:
import { GraphQLError } from 'graphql'; import { MiddlewareFn } from 'type-graphql'; export const ErrorInterceptor: MiddlewareFn<any> = async ( { context, info }, next ) => { try { return await next(); } catch (err) { if (err !== null && typeof err === 'object') { if ('validationErrors' in err) { throw new GraphQLError( 'Validation failed', null, null, null, null, null, { code: 'ARGUMENT_VALIDATION_ERROR', validationErrors: err.validationErrors, } ); } else { console.log( '\x1b[31m', `SERVER ERROR: ${new Date().toLocaleTimeString()}\n`, '\x1b[33m', err, 'CONTEXT: \n\x1b[32m', context, 'INFO: \n\x1b[35m', info ); } throw err; } else { throw err; } } };
This will intercept validation errors that may arise in runtime and outputs a modified response to the client. Next, create Crypto.service.ts inside services and paste the following code:
import 'reflect-metadata'; import { Service } from 'typedi'; @Service() export class CryptoService {}
Followed by creating Crypto.resolver.ts inside resolvers folder and again, paste the following code:
import { CryptoService } from '@graphql/services/Crypto.service'; import 'reflect-metadata'; import { Resolver } from 'type-graphql'; import { Service } from 'typedi'; @Service() @Resolver() export class CryptoResolver { // eslint-disable-next-line no-unused-vars constructor(private readonly cryptoService: CryptoService) {} }
To finalize the critical contents of graphql folder, create schema.ts file inside the graphql folder and paste the following:
import { ErrorInterceptor } from '@graphql/middlewares/error.interceptor'; import { CryptoResolver } from '@graphql/resolvers/Crypto.resolver'; import { buildSchema } from 'type-graphql'; import { Container } from 'typedi'; const schema = await buildSchema({ resolvers: [CryptoResolver], globalMiddlewares: [ErrorInterceptor], emitSchemaFile: process.env.NODE_ENV === 'development' ? true : false, container: Container, }); export default schema;
Before we proceed, let's discuss first all the folders and files involved. If you have tried Nest.JS before, the structure provided was loosely based on how Nest.JS organize its codebase. If you haven't tried Nest.JS, here is the breakdown of structure. Middlewares, like error interceptor, execute before the response is delivered to the client. You can add any logic you want, whether to modify, remove or append additional data. In our case, we provide relevant validation error to the client side. Resolvers folder contains all the resolvers file you may want to create, but what is a resolver? Resolver defines all the query and mutation types to be supported and handled by the GraphQL server. Resolvers are named based on the collective functionalities of its queries and mutations. But the logic necessary to execute the desired operation lies on the service files inside services folder. Service file contains all the methods to perform the wanted logic which is then called and executed by the resolver methods. Resolver also use types to model and validate any inbound and outbound data. These types are declared inside types folder which will be covered later. They can be thought of as data transfer objects or DTOs which defines the shapes of the inputs and outputs. This layered design provides separation of concerns and makes the implementation modular where resolvers receive and call the corresponding service while the service executes the actual operation. Finally, Middlewares and resolvers are declared inside the schema file which automatically generates the SDL based on resolvers and types. emitSchemaFile is allowed in development environment for the graphql-codegen to read and generate custom hooks later. Emission of schema file is disabled on production to prevent write error when deployed to Vercel as calling graphQL endpoint automatically generates a new SDL file.
Finally, in order for our GraphQL infrastructure to be available as an API, we need to setup the server inside the api routes folder of nextjs. Inside /pages/api, delete the index.ts, replace that with graphql.ts and paste the snippet below:
import schema from '@graphql/schema'; import { createYoga, maskError } from 'graphql-yoga'; import type { NextApiRequest, NextApiResponse } from 'next'; export default createYoga<{ req: NextApiRequest; res: NextApiResponse; }>({ schema, graphqlEndpoint: '/api/graphql', maskedErrors: { maskError(error: any, message, isDev) { if (error?.extensions?.code === 'ARGUMENT_VALIDATION_ERROR') { return error; } return maskError(error, message, isDev); }, }, }); export const config = { api: { bodyParser: false, }, };
This file creates a GraphQL server exposed at /api/graphql which can be accessed by the frontend layer. MaskErrors determines whether a certain type of error should reach to the client or not. Error interceptor we created earlier creates ARGUMENT_VALIDATION_ERROR which is allowed by the mask to pass and reach the client.
Before we can test our API on a playground, we should first define at least one query in our resolver because GraphQL mandates it. This time, we'll implement all the necessary endpoints before testing the API on the playground.
First, open your package.json and update the dev script:
"dev": "cross-env NODE_OPTIONS='--inspect' NODE_ENV=development next dev",
This update allows us to declare the env we have at development, prompting the type-graphql to emit an SDL file for us.
Create /lib/enums inside src folder, create a file named crypto.enums.ts, and paste the following code:
/* eslint-disable no-unused-vars */ export enum EncryptionAction { ENCRYPT = 'ENCRYPT', DECRYPT = 'DECRYPT', }
Create /lib/utils inside your src folder, create a file named crypto.utils.ts, and paste the following code:
import { EncryptionAction } from '@enums/crypto.enums'; import crypto from 'crypto'; export const hashData = (data: string, hashAlgo: string) => { return crypto.createHash(hashAlgo).update(data).digest('base64'); }; export const generateHMAC = ( data: string, hashAlgo: string, secret: string ) => { return crypto.createHmac(hashAlgo, secret).update(data).digest('base64'); }; export const generateSymmetricKey = () => { return crypto.randomBytes(32).toString('base64'); }; export const generateIV = () => { return crypto.randomBytes(16).toString('base64'); }; export const generateRSAPair = (bitLength: number) => { const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: bitLength, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); return { publicKey: Buffer.from(publicKey).toString('base64'), privateKey: Buffer.from(privateKey).toString('base64'), }; }; export const generateECPair = (curveName: string) => { const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: curveName, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); return { publicKey: Buffer.from(publicKey).toString('base64'), privateKey: Buffer.from(privateKey).toString('base64'), }; }; export const symmetricAction = ( action: EncryptionAction, data: string, key: string, algo: string, iv: string ) => { const translatedKey = Buffer.from(key, 'base64'); const ivBuffer = Buffer.from(iv, 'base64'); switch (action) { case EncryptionAction.ENCRYPT: { const cipher = crypto.createCipheriv(algo, translatedKey, ivBuffer); return cipher.update(data, 'utf8', 'base64') + cipher.final('base64'); } case EncryptionAction.DECRYPT: { const decipher = crypto.createDecipheriv(algo, translatedKey, ivBuffer); return decipher.update(data, 'base64', 'utf-8') + decipher.final('utf8'); } } }; export const asymmetricAction = ( action: EncryptionAction, data: string, key: string ) => { const recodedKey = Buffer.from(key, 'base64'); switch (action) { case EncryptionAction.ENCRYPT: { return crypto .publicEncrypt(recodedKey, Buffer.from(data)) .toString('base64'); } case EncryptionAction.DECRYPT: { return crypto .privateDecrypt(recodedKey, Buffer.from(data, 'base64')) .toString('utf-8'); } } }; export const signData = ( data: string, hashAlgo: string, privateKey: string ) => { const signer = crypto.createSign(hashAlgo); const recodedPrivateKey = Buffer.from(privateKey, 'base64'); signer.update(data); return signer.sign(recodedPrivateKey, 'base64'); }; export const verifySignature = ( data: string, hashAlgo: string, publicKey: string, signature: string ) => { const verifier = crypto.createVerify(hashAlgo); const recodedPublicKey = Buffer.from(publicKey, 'base64'); verifier.update(data); return verifier.verify(recodedPublicKey, signature, 'base64'); };
These functions are derived from my previous blog about Cryptography.
Next, create /lib/models inside your src folder, create a file named crypto.models.ts, and paste the following code:
import { EncryptionAction } from '@enums/crypto.enums'; export interface IHashInput { data: string; hashAlgo: string; } export interface IHMACInput extends IHashInput { secret: string; } export interface IRSAKeyPairInput { modulusLength: number; } export interface IECKeyPairInput { namedCurve: string; } export interface IAsymmetricActionInput { action: EncryptionAction; data: string; key: string; } export interface ISymmetricActionInput extends IAsymmetricActionInput { algo: string; iv: string; } export interface ISignDataInput { data: string; hashAlgo: string; key: string; } export interface IVerifyDataInput extends ISignDataInput { signature: string; } export interface ICryptographicOutput { output: string; } export interface IKeyPairOutput { publicKey: string; privateKey: string; } export interface IVerificationOutput { isValid: boolean; }
Models folder will hold all the interfaces/types to be used across the codebase and will served as a single source of truth for all user-defined types.
Next, go to /graphql/types, create a file named crypto.types.ts and paste the following:
import { EncryptionAction } from '@enums/crypto.enums'; import { IAsymmetricActionInput, ICryptographicOutput, IECKeyPairInput, IHashInput, IHMACInput, IKeyPairOutput, IRSAKeyPairInput, ISignDataInput, ISymmetricActionInput, IVerificationOutput, IVerifyDataInput, } from '@models/crypto.models'; import { IsNotEmpty, IsNumber } from 'class-validator'; import { Field, InputType, ObjectType } from 'type-graphql'; @InputType() export class HashInput implements IHashInput { @IsNotEmpty() @Field() data!: string; @Field() hashAlgo!: string; } @InputType() export class HMACInput extends HashInput implements IHMACInput { @Field() secret!: string; } @InputType() export class RSAKeyPairInput implements IRSAKeyPairInput { @IsNumber() @Field() modulusLength!: number; } @InputType() export class ECKeyPairInput implements IECKeyPairInput { @IsNotEmpty() @Field() namedCurve!: string; } @InputType() export class AsymmetricActionInput implements IAsymmetricActionInput { @IsNotEmpty() @Field((_type) => EncryptionAction) action!: EncryptionAction; @Field() data!: string; @Field() key!: string; } @InputType() export class SymmetricActionInput extends AsymmetricActionInput implements ISymmetricActionInput { @Field() algo!: string; @Field() iv!: string; } @InputType() export class SignDataInput implements ISignDataInput { @IsNotEmpty() @Field() data!: string; @Field() hashAlgo!: string; @Field() key!: string; } @InputType() export class VerifyDataInput extends SignDataInput implements IVerifyDataInput { @Field() signature!: string; } @ObjectType() export class CryptographicOutput implements ICryptographicOutput { @Field() output!: string; } @ObjectType() export class KeyPairOutput implements IKeyPairOutput { @Field() publicKey!: string; @Field() privateKey!: string; } @ObjectType() export class VerificationOutput implements IVerificationOutput { @Field() isValid!: boolean; }
@InputType() decorator is used for specifying input. A similar decorator called @ArgsType() can also be used but the difference is that, @InputType() retains the 'object' shape of the input while @ArgsType() spreads the properties of an input object. For the output, @ObjectType() decorator is used. @Field() decorator is used for every field of the class. Primitive types can be recognized by the type-graphql but complex types like Enums ans Arrays requires the @Field() to be passed with function signature that returns the type you want. Class validator decorators like @IsNumber() and @IsNotEmpty() can be declared for every class fields.
After setting up all the typings, we'll now implement the contents of resolver and services. Let's start with crypto.service.ts. Paste the following inside the class body.
import { IAsymmetricActionInput, IECKeyPairInput, IHashInput, IHMACInput, IRSAKeyPairInput, ISignDataInput, ISymmetricActionInput, IVerifyDataInput, } from '@models/crypto.models'; import * as cryptoUtils from '@utils/crypto.utils'; import 'reflect-metadata'; import { Service } from 'typedi'; @Service() export class CryptoService { hashData(input: IHashInput) { return cryptoUtils.hashData(input.data, input.hashAlgo); } generateHMAC(input: IHMACInput) { return cryptoUtils.generateHMAC(input.data, input.hashAlgo, input.secret); } generateSymmetricKey() { return cryptoUtils.generateSymmetricKey(); } generateIV() { return cryptoUtils.generateIV(); } generateRSAPair(input: IRSAKeyPairInput) { return cryptoUtils.generateRSAPair(input.modulusLength); } generateECPair(input: IECKeyPairInput) { return cryptoUtils.generateECPair(input.namedCurve); } symmetricAction(input: ISymmetricActionInput) { return cryptoUtils.symmetricAction( input.action, input.data, input.key, input.algo, input.iv ); } asymmetricAction(input: IAsymmetricActionInput) { return cryptoUtils.asymmetricAction(input.action, input.data, input.key); } signData(input: ISignDataInput) { return cryptoUtils.signData(input.data, input.hashAlgo, input.key); } verifySignature(input: IVerifyDataInput) { return cryptoUtils.verifySignature( input.data, input.hashAlgo, input.key, input.signature ); } }
Crypto utils are what really implements the logic. We can put the logic inside the service itself but the logic can also be use not only for api purposes but for other internal applications in the future. Finally, add, all queries and mutations inside the Crypto.resolver.ts class body;
import { EncryptionAction } from '@enums/crypto.enums'; import { CryptoService } from '@graphql/services/Crypto.service'; import { AsymmetricActionInput, CryptographicOutput, ECKeyPairInput, HashInput, HMACInput, KeyPairOutput, RSAKeyPairInput, SignDataInput, SymmetricActionInput, VerificationOutput, VerifyDataInput, } from '@graphql/types/crypto.types'; import 'reflect-metadata'; import { Arg, Mutation, Query, registerEnumType, Resolver } from 'type-graphql'; import { Service } from 'typedi'; registerEnumType(EncryptionAction, { name: 'EncryptionAction', }); @Service() @Resolver() export class CryptoResolver { // eslint-disable-next-line no-unused-vars constructor(private readonly cryptoService: CryptoService) {} @Query((_returns) => CryptographicOutput) hashData(@Arg('input') data: HashInput): CryptographicOutput { return { output: this.cryptoService.hashData(data) }; } @Query((_returns) => CryptographicOutput) generateHMAC(@Arg('input') data: HMACInput): CryptographicOutput { return { output: this.cryptoService.generateHMAC(data) }; } @Query((_returns) => CryptographicOutput) generateSymmetricKey(): CryptographicOutput { return { output: this.cryptoService.generateSymmetricKey() }; } @Query((_returns) => CryptographicOutput) generateIV(): CryptographicOutput { return { output: this.cryptoService.generateIV() }; } @Query((_returns) => KeyPairOutput) generateRSAPair(@Arg('input') data: RSAKeyPairInput): KeyPairOutput { return this.cryptoService.generateRSAPair(data); } @Query((_returns) => KeyPairOutput) generateECPair(@Arg('input') data: ECKeyPairInput): KeyPairOutput { return this.cryptoService.generateECPair(data); } @Mutation((_returns) => CryptographicOutput) symmetricAction( @Arg('input') data: SymmetricActionInput ): CryptographicOutput { return { output: this.cryptoService.symmetricAction(data) }; } @Mutation((_returns) => CryptographicOutput) asymmetricAction( @Arg('input') data: AsymmetricActionInput ): CryptographicOutput { return { output: this.cryptoService.asymmetricAction(data) }; } @Mutation((_returns) => CryptographicOutput) signData(@Arg('input') data: SignDataInput): CryptographicOutput { return { output: this.cryptoService.signData(data) }; } @Mutation((_returns) => VerificationOutput) verifySignature(@Arg('input') data: VerifyDataInput): VerificationOutput { return { isValid: this.cryptoService.verifySignature(data) }; } }
Take note of registerEnumType declaration above the class, this is important as it allows type-graphql to recognize our enum and be able to generate in SDL.
With everything setup, we can now start our dev server by running yarn dev in the terminal and opening http://localhost:3000/api/graphql
Implementing the Frontend Layer
For this next phase, we'll implement a simple UI for the user to interact with. But before we create the UI, we'll write the queries first. Inside /graphql/queries, create a file named Crypto.queries.ts and paste the queries below:
import { gql } from '@apollo/client'; export const HashData = gql(` query HashData($input: HashInput!) { hashData(input: $input) { output } } `); export const GenerateHMAC = gql(` query GenerateHMAC($input: HMACInput!) { generateHMAC(input: $input) { output } } `); export const GenerateSymmetricKey = gql(` query GenerateSymmetricKey { generateSymmetricKey { output } } `); export const GenerateIV = gql(` query GenerateIV { generateIV { output } } `); export const GenerateRSAPair = gql(` query GenerateRSAPair($input: RSAKeyPairInput!) { generateRSAPair(input: $input) { publicKey privateKey } } `); export const GenerateECPair = gql(` query GenerateECPair($input: ECKeyPairInput!) { generateECPair(input: $input) { publicKey privateKey } } `); export const SymmetricAction = gql(` mutation SymmetricAction($input: SymmetricActionInput!) { symmetricAction(input: $input) { output } } `); export const AsymmetricAction = gql(` mutation AsymmetricAction($input: AsymmetricActionInput!) { asymmetricAction(input: $input) { output } } `); export const SignData = gql(` mutation SignData($input: SignDataInput!) { signData(input: $input) { output } } `); export const VerifySignature = gql(` mutation VerifySignature($input: VerifyDataInput!) { verifySignature(input: $input) { isValid } } `);
Make sure you save everything and local dev server is running. Open or refresh the GraphQL playground. This action regenerates SDL file on our codebase. On the terminal, run the command:
yarn codegen
This command should be executed every time there are any changes in your SDL or queries to align custom hook typings with the updated schema.
Next, create client.ts inside graphql folder on the same level as schema.ts and paste the following code.
import { ApolloClient, InMemoryCache } from '@apollo/client'; const apolloClient = new ApolloClient({ uri: '/api/graphql', cache: new InMemoryCache(), }); export default apolloClient;
This code exports an apollo client to be used by ApolloProvider, allowing us to make queries in our frontend layer. To configure ApolloProvider, go to _app.tsx and wrap the Component declaration with ApolloProvider and pass the exported client to the ApolloProvider client prop.
import apolloClient from '@/graphql/client'; import '@/styles/globals.css'; import { ApolloProvider } from '@apollo/client'; import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return ( <ApolloProvider client={apolloClient}> <Component {...pageProps} /> </ApolloProvider> ); }
Next is to create the UI. Pasting the code here would be too long, you can check the repository later and copy the code. Open your index.tsx inside pages folder and replace the content with the code from the repository same with the /src/styles/Home.module.css.
To complete out app, let's disable the caching of apollo client and set error policy to all. This allows us to repeatedly execute same cryptographic action like generating keys and IV and catch all errors through errorPolicy. Open your /src/graphql/client.ts file and paste the following code.
import { ApolloClient, InMemoryCache } from '@apollo/client'; const apolloClient = new ApolloClient({ uri: '/api/graphql', cache: new InMemoryCache(), defaultOptions: { watchQuery: { fetchPolicy: 'no-cache', errorPolicy: 'all', }, query: { fetchPolicy: 'no-cache', errorPolicy: 'all', }, }, }); export default apolloClient;
Take note, you don't have to do this on your future projects. As much as possible enable apollo client caching to save repeated network request. In our case, we need to disable caching so we can continuously generate different keys and IV without refreshing the app or invalidating each query calls. Lastly, GraphQL queries were consumed using lazy version of the query hooks as it returns a method that can be manually called and executed compared to non-lazy query hook that automatically calls graphql query on page load.
Tthere you have it, a full stack Next.JS graphQL project. You can visit this link to try the deployed app next-crypto.
Repository is available here.


