Using GraphQL Effectively with NextJS and NestJS

Introduction

GraphQL is a powerful query language for APIs which optimizes performance by allowing the client side to accurately define the required data structure, completely overcoming the over-fetching and under-fetching disadvantages of traditional REST APIs.

In this article we will set up the connection environment as follows: Client connects to NextJS server which acts as a middleware to forward the GraphQL payload to NestJS. You will see that most of the implementation on NestJS will be quite simple and most of the content is auto-generated code via prisma-nestjs-graphql. It is suitable for projects that have complex processing requirements concentrated on the frontend and applying GraphQL will bring high flexibility in querying data so that the team can focus on building features for the frontend effectively.

Prerequisites

Please take a look at this article where I have specific instructions on setting up GraphQL for NextJS, here on the FE side we will use @tanstack/react-query and graphql-request to query data so you may not need to install @apollo/client.

Detail

NestJS project

First, install the following packages:

yarn add graphql @nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/express5
yarn add -D prisma-nestjs-graphql @prisma/generator-helper

These packages provide the core libraries of GraphQL, the module integrating Apollo Server into NestJS along with auto-generation tools to synchronize the database structure from Prisma into input types in the GraphQL system.

Create file prisma/schema.prisma:

datasource db {
  provider = "postgresql"
}

generator client {
  provider = "prisma-client-js"
}

generator nestgraphql {
  provider = "prisma-nestjs-graphql"
  output   = "../gen/graphql"
}

model Product {
  id        Int      @id @default(autoincrement())
  name      String
  price     Float
  createdAt DateTime @default(now())
}

This Prisma configuration block declares the PostgreSQL data source, creates a standard Prisma Client and uses the prisma-nestjs-graphql generator to automatically map the Product table structure into objects, filters and argument types completely compatible with NestJS GraphQL.

Update file package.json to add the following scripts:

{
  "scripts": {
    "prisma:migrate": "prisma migrate dev --name",
    "prisma:generate": "prisma generate"
  }
}

Please run the following command to create the migrate file and update the database:

yarn prisma:migrate

Then execute this command to generate the data types for GraphQL:

$ yarn prisma:generate
yarn run v1.22.22
$ prisma generate
Loaded Prisma config from prisma.config.ts.
Prisma schema loaded from prisma/schema.prisma.

✔ Generated Prisma Client (v7.8.0) to ./node_modules/@prisma/client in 51ms
✔ Generated Prisma NestJS/GraphQL to ./src/@generated in 71ms

Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)

✨  Done in 1.23s.


You can check the results of the generated files as follows.


Create file src/resolver/product.resolver.ts:

import {Args, Mutation, Query, Resolver} from '@nestjs/graphql'
import {CreateOneProductArgs} from 'gen/graphql/product/create-one-product.args'
import {DeleteOneProductArgs} from 'gen/graphql/product/delete-one-product.args'
import {FindManyProductArgs} from 'gen/graphql/product/find-many-product.args'
import {Product} from 'gen/graphql/product/product.model'
import {UpdateOneProductArgs} from 'gen/graphql/product/update-one-product.args'
import {PrismaService} from 'src/service/prisma.service'

@Resolver(() => Product)
export class ProductResolver {
  constructor(private readonly prisma: PrismaService) {}

  @Query(() => [Product], {name: 'products'})
  getProducts(@Args() args: FindManyProductArgs) {
    return this.prisma.product.findMany(args)
  }

  @Mutation(() => Product, {name: 'createProduct'})
  createProduct(@Args() args: CreateOneProductArgs) {
    return this.prisma.product.create(args)
  }

  @Mutation(() => Product, {name: 'updateProduct'})
  updateProduct(@Args() args: UpdateOneProductArgs) {
    return this.prisma.product.update(args)
  }

  @Mutation(() => Product, {name: 'deleteProduct'})
  deleteProduct(@Args() args: DeleteOneProductArgs) {
    return this.prisma.product.delete(args)
  }
}

This class acts as a GraphQL Resolver responsible for handling CRUD business logic, using NestJS @Query and @Mutation decorators combined directly with data type definitions automatically generated from Prisma to manipulate the database via PrismaService.

Update file src/app.module.ts:

import {ApolloServerPluginLandingPageLocalDefault} from '@apollo/server/plugin/landingPage/default'
import {ApolloDriver, ApolloDriverConfig} from '@nestjs/apollo'
import {
  MiddlewareConsumer,
  Module,
  NestModule,
  RequestMethod,
} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {GraphQLModule} from '@nestjs/graphql'
import {join} from 'path'
import {OriginAuthMiddleware} from './middleware/origin-auth.middleware'
import {ProductResolver} from './resolver/product.resolver'
import {EnvironmentService} from './service/environment.service'
import {PrismaService} from './service/prisma.service'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'gen/graphql/schema.gql'),
      sortSchema: true,
      playground: false,
      plugins: [
        ApolloServerPluginLandingPageLocalDefault({
          embed: true,
        }),
      ],
    }),
  ],
  providers: [
    EnvironmentService,
    PrismaService,
    ProductResolver,
  ],
})
export class AppModule {}

The configuration code for the main module (AppModule) of the NestJS application initialization GraphQLModule uses the Apollo driver, configures the automatic generation of the GraphQL schema file (schema.gql), sorts schema fields and integrates the local Apollo Sandbox interface replacing the old Playground to test the API visually.


After starting by default it will support the old Playground like this.


When switching to the newer Apollo Sandbox it will be like this.


Update file src/main.ts:

import {NestFactory} from '@nestjs/core'
import type {NestExpressApplication} from '@nestjs/platform-express'
import {AppModule} from 'src/app.module'

export async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)
  const port = process.env.PORT ?? 4000
  await app.listen(port)
}
bootstrap()

NextJS project

Please install the following packages:

yarn add graphql-request

Add the following scripts to the package.json file:

{
  "scripts": {
    "graphql:gen": "graphql-codegen",
    "graphql:genw": "graphql-codegen --watch"
  }
}

Create file app/api/graphql/route.ts:

import {NextRequest, NextResponse} from 'next/server'

const host = process.env.SERVER_GRAPHQL_HOST || ''

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const res = await fetch(host, {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(body),
    })
    const data = await res.json()
    return NextResponse.json(data)
  } catch (error: any) {
    return NextResponse.json(
      {errors: [{message: error.message}]},
      {status: 500}
    )
  }
}

This file defines a Route Handler in NextJS acting as an intermediary proxy connection, it receives GraphQL query payloads from the Client, forwards requests to the NestJS Backend Server via the internal network environment to secure the real endpoint and avoid CORS policy conflicts.

Create file app/feature/graphql/product.graphql.ts:

import {gql} from '@/gql'

export const GET_PRODUCTS = gql(`
  query GetProducts(
    $where: ProductWhereInput
    $orderBy: [ProductOrderByWithRelationInput!]
  ) {
    products(where: $where, orderBy: $orderBy) {
      id
      name
      price
      createdAt
    }
  }
`)

export const CREATE_PRODUCT = gql(`
  mutation CreateProduct($data: ProductCreateInput!) {
    createProduct(data: $data) {
      id
    }
  }
`)

export const UPDATE_PRODUCT = gql(`
  mutation UpdateProduct(
    $data: ProductUpdateInput!
    $where: ProductWhereUniqueInput!
  ) {
    updateProduct(data: $data, where: $where) {
      id
    }
  }
`)

export const DELETE_PRODUCT = gql(`
  mutation DeleteProduct($where: ProductWhereUniqueInput!) {
    deleteProduct(where: $where) {
      id
    }
  }
`)

The code defines the structure of specific Query and Mutation operations on the Client side by using the gql tag, these query strings help the GraphQL Code Generator system scan through and automatically map into strictly typed data types and API calling methods in TypeScript.

Then execute the command to generate types for GraphQL:

$ yarn graphql:gen
yarn run v1.22.22
$ graphql-codegen
✔ Parse Configuration
✔ Generate outputs
✨  Done in 1.02s.

Create file app/graphql/product.hook.ts:

import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {message} from 'antd'
import {request} from 'graphql-request'
import {useState} from 'react'
import {
  CREATE_PRODUCT,
  DELETE_PRODUCT,
  GET_PRODUCTS,
  UPDATE_PRODUCT,
} from './product.graphql'

const API_URL = process.env.NEXT_PUBLIC_GRAPHQL_HOST || ''

export interface ProductSortState {
  field: 'name' | 'price' | null
  order: 'asc' | 'desc' | null
}

export function useProductQueries() {
  const queryClient = useQueryClient()
  const [searchName, setSearchName] = useState('')
  const [sort, setSort] = useState<ProductSortState>({field: null, order: null})

  const orderBy =
    sort.field && sort.order ? [{[sort.field]: sort.order}] : undefined

  const {data, isLoading} = useQuery({
    queryKey: ['products', searchName, sort],
    queryFn: () =>
      request(API_URL, GET_PRODUCTS, {
        where: searchName ? {name: {contains: searchName}} : undefined,
        orderBy,
      }),
  })

  const createMutation = useMutation({
    mutationFn: (values: {name: string; price: number}) =>
      request(API_URL, CREATE_PRODUCT, {data: values}),
    onSuccess: () => {
      message.success('Product created successfully!')
      queryClient.invalidateQueries({queryKey: ['products']})
    },
    onError: () => message.error('Failed to create product.'),
  })

  const updateMutation = useMutation({
    mutationFn: (values: {id: number; name: string; price: number}) =>
      request(API_URL, UPDATE_PRODUCT, {
        data: {name: values.name, price: values.price},
        where: {id: Number(values.id)},
      }),
    onSuccess: () => {
      message.success('Product updated successfully!')
      queryClient.invalidateQueries({queryKey: ['products']})
    },
    onError: () => message.error('Failed to update product.'),
  })

  const deleteMutation = useMutation({
    mutationFn: (id: number) =>
      request(API_URL, DELETE_PRODUCT, {where: {id: Number(id)}}),
    onSuccess: () => {
      message.success('Product deleted successfully!')
      queryClient.invalidateQueries({queryKey: ['products']})
    },
    onError: () => message.error('Failed to delete product.'),
  })

  return {
    products: data?.products || [],
    isLoading,
    searchName,
    setSearchName,
    sort,
    setSort,
    createMutation,
    updateMutation,
    deleteMutation,
  }
}

This Custom Hook encapsulates the entire data interaction logic through the combination of graphql-request and @tanstack/react-query libraries, it manages searching states, list sorting and automatically triggers data cache refreshing (invalidateQueries) as soon as add, edit or delete actions succeed.

Create file app/graphql/ProductPageClient.tsx:

'use client'

import {
  DeleteOutlined,
  EditOutlined,
  PlusOutlined,
  SearchOutlined,
} from '@ant-design/icons'
import {
  Button,
  Form,
  Input,
  InputNumber,
  Modal,
  Popconfirm,
  Space,
  Table,
} from 'antd'
import {useState} from 'react'
import {useProductQueries} from './product.hook'

export default function ProductPageClient() {
  const [form] = Form.useForm()
  const [editingId, setEditingId] = useState<number | null>(null)
  const [isModalOpen, setIsModalOpen] = useState(false)

  const {
    products,
    isLoading,
    setSearchName,
    setSort,
    createMutation,
    updateMutation,
    deleteMutation,
  } = useProductQueries()

  const openModal = (record?: any) => {
    if (record) {
      setEditingId(record.id)
      form.setFieldsValue(record)
    } else {
      setEditingId(null)
      form.resetFields()
    }
    setIsModalOpen(true)
  }

  const closeModal = () => {
    setIsModalOpen(false)
    form.resetFields()
  }

  const handleSubmit = (values: any) => {
    if (editingId) {
      updateMutation.mutate(
        {id: editingId, ...values},
        {
          onSuccess: () => closeModal(),
        }
      )
    } else {
      const {id, ...createData} = values

      createMutation.mutate(
        createData,
        {
          onSuccess: () => closeModal(),
        }
      )
    }
  }

  const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
    const currentSorter = Array.isArray(sorter) ? sorter[0] : sorter

    if (currentSorter && currentSorter.order) {
      setSort({
        field: currentSorter.field,
        order: currentSorter.order === 'ascend' ? 'asc' : 'desc',
      })
    } else {
      setSort({field: null, order: null})
    }
  }

  const columns = [
    {title: 'ID', dataIndex: 'id', key: 'id', width: 80, sorter: true},
    {
      title: 'Product Name',
      dataIndex: 'name',
      key: 'name',
      sorter: true,
      className: 'font-semibold text-slate-700',
    },
    {
      title: 'Price',
      dataIndex: 'price',
      key: 'price',
      sorter: true,
      render: (price: number) =>
        price.toLocaleString(undefined, {
          minimumFractionDigits: 2,
          maximumFractionDigits: 2,
        }),
    },
    {
      title: 'Actions',
      key: 'actions',
      width: 180,
      render: (_: any, record: any) => (
        <Space size="middle">
          <Button
            type="link"
            icon={<EditOutlined />}
            onClick={() => openModal(record)}
          >
            Edit
          </Button>
          <Popconfirm
            title="Are you sure to delete this product?"
            onConfirm={() => deleteMutation.mutate(record.id)}
            okText="Yes"
            cancelText="No"
          >
            <Button
              type="link"
              danger
              icon={<DeleteOutlined />}
              loading={deleteMutation.isPending}
            >
              Delete
            </Button>
          </Popconfirm>
        </Space>
      ),
    },
  ]

  return (
    <div className="p-8 max-w-6xl mx-auto bg-slate-50 min-h-screen">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold text-slate-800">
          Products Management System
        </h1>
        <Button
          type="primary"
          className="bg-blue-600"
          icon={<PlusOutlined />}
          onClick={() => openModal()}
        >
          Add Product
        </Button>
      </div>

      <div className="bg-white p-4 rounded-lg shadow-sm mb-6">
        <Input
          placeholder="Search by product name..."
          className="max-w-xs"
          allowClear
          prefix={<SearchOutlined className="text-slate-400" />}
          onChange={e => setSearchName(e.target.value)}
        />
      </div>

      <div className="bg-white rounded-lg shadow-sm overflow-hidden">
        <Table
          dataSource={products}
          columns={columns}
          loading={isLoading}
          rowKey="id"
          pagination={{pageSize: 5}}
          onChange={handleTableChange}
        />
      </div>

      <Modal
        title={editingId ? 'Update Product' : 'Create New Product'}
        open={isModalOpen}
        onCancel={closeModal}
        onOk={() => form.submit()}
        okText="Save"
        confirmLoading={createMutation.isPending || updateMutation.isPending}
      >
        <Form
          form={form}
          layout="vertical"
          onFinish={handleSubmit}
          className="mt-4"
        >
          <Form.Item
            name="name"
            label="Product Name"
            rules={[{required: true, message: 'Please input product name!'}]}
          >
            <Input placeholder="Enter product name" />
          </Form.Item>
          <Form.Item
            name="price"
            label="Price"
            rules={[{required: true, message: 'Please input product price!'}]}
          >
            <InputNumber className="w-full" min={0} placeholder="0.00" />
          </Form.Item>
        </Form>
      </Modal>
    </div>
  )
}

This code creates a Client Component that manages the user interface tier, it provides a product list display table, modal-style entry fields, sorting event capturing (handleTableChange) and handles refining payloads appropriately depending on whether the task is creating or updating a record.

Create file app/graphql/page.tsx:

import {dehydrate, HydrationBoundary, QueryClient} from '@tanstack/react-query'
import {request} from 'graphql-request'
import {GET_PRODUCTS} from './product.graphql'
import ProductPageClient from './ProductPageClient'

const API_URL = process.env.NEXT_PUBLIC_GRAPHQL_HOST || ''

export default async function ProductsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['products', '', {field: null, order: null}],
    queryFn: () =>
      request(API_URL, GET_PRODUCTS, {where: undefined, orderBy: undefined}),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductPageClient />
    </HydrationBoundary>
  )
}

This file defines a Server Component leveraging server-side rendering (SSR) capabilities, performs a prefetch mechanism to load the product list data early directly on the Node.js Server environment, then uses dehydrate to pack the data and forward it via HydrationBoundary down to the Client Component, bringing optimal display speed and enhancing user experience.


Checking the results will be like follows.





Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

Sitemap

DevOps Practice Series