Building a GraphQL and gRPC System on NextJS and NestJS

Introduction

GraphQL and gRPC are two powerful and popular communication technologies today. While GraphQL optimizes data transmission between Client and Server by allowing the Client to query exactly what it needs, gRPC is an ideal solution for communication between Microservices thanks to its superior performance, based on the HTTP/2 protocol and the Protocol Buffers binary format. The combination of this duo brings comprehensive optimization from the user interface layer to the core backend system.


Using modern packages from @bufbuild and @connectrpc brings many outstanding advantages compared to the traditional library @grpc/grpc-js:

  • Comprehensive Typescript support: Automatically generates safe type files (Type-safe) intuitively, helping the coding process to be error-free and providing excellent code suggestions (IntelliSense).
  • Perfect compatibility with HTTP/1.1 and HTTP/2: No need to configure complex proxies like Envoy to connect from the browser or restricted environments, thanks to the mechanism supporting both Connect and gRPC-Web protocols.
  • Modern, minimalist syntax: Eliminates cumbersome boilerplate code segments, making the definition of services and routers concise just like writing standard API Endpoints.

Prerequisites

This article is a continuation of my previous articles with the requirement that you have already set up Prisma and used @bufbuild @connectrpc to connect gRPC, you can review them to prepare because I will no longer provide specific instructions for those 2 parts in this article.


Detail

Common setup

This is the common configuration part for both NextJS and NestJS projects, as usual, the proto files are shared between the 2 projects and need to be generated into the corresponding types to be used with Typescript.

In the implementation sections below, if there is any file that I do not provide, it means it was already included in the previous tutorial articles.


Create file proto/product.proto

syntax = "proto3";
package product;

service ProductService {
rpc FindAll (Empty) returns (ProductList) {}
rpc Create (CreateProductInput) returns (Product) {}
rpc Update (UpdateProductInput) returns (Product) {}
rpc Delete (ProductId) returns (DeleteResponse) {}
}

message Empty {}
message ProductId { int32 id = 1; }
message DeleteResponse { bool success = 1; }

message Product {
int32 id = 1;
string name = 2;
double price = 3;
}

message ProductList { repeated Product products = 1; }

message CreateProductInput {
string name = 1;
double price = 2;
}

message UpdateProductInput {
int32 id = 1;
string name = 2;
double price = 3;
}


Run this command to create types for the proto file:

yarn generate


The result generates the following files:

NestJS project

First, please install the package:

yarn add @connectrpc/connect-fastify


Create file src/service/product.service.ts

import {ConnectRouter} from '@connectrpc/connect'
import {Injectable} from '@nestjs/common'
import {ProductService} from 'gen/product_pb'
import {PrismaService} from 'src/service/prisma.service'

@Injectable()
export class ProductHandlerService {
constructor(private readonly prisma: PrismaService) {}

register(router: ConnectRouter) {
router.service(ProductService, {
findAll: async () => {
const products = await this.prisma.product.findMany({
orderBy: {id: 'desc'},
})
return {products}
},

create: async req => {
return await this.prisma.product.create({
data: {name: req.name, price: req.price},
})
},

update: async req => {
return await this.prisma.product.update({
where: {id: req.id},
data: {name: req.name, price: req.price},
})
},

delete: async req => {
await this.prisma.product.delete({where: {id: req.id}})
return {success: true}
},
})
}
}

The code above defines ProductHandlerService, a service that handles the business logic for the gRPC methods described in the proto file (findAll, create, update, delete). This service uses Prisma to interact directly with the database, then maps these methods into ConnectRouter via the register function, turning them into gRPC endpoints ready to receive requests.


Update file src/app.module.ts

import {Module} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {PrismaService} from './service/prisma.service'
import {ProductHandlerService} from './service/product.service'

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
],
providers: [
PrismaService,
ProductHandlerService,
],
})
export class AppModule {}


Update file src/main.ts

import {fastifyConnectPlugin} from '@connectrpc/connect-fastify'
import {NestFactory} from '@nestjs/core'
import {FastifyAdapter, NestFastifyApplication} from '@nestjs/platform-fastify'
import {AppModule} from 'src/app.module'
import {ProductHandlerService} from 'src/service/product.service'

async function bootstrap() {
const adapter = new FastifyAdapter({
http2: true,
})
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
adapter
)

const fastifyInstance = adapter.getInstance()

fastifyInstance.addHook('onRequest', async (request, reply) => {
const httpVersion = request.raw.httpVersion
const contentType = request.headers['content-type'] || ''
console.log(`httpVersion: ${httpVersion} | contentType: ${contentType}`)
})

const productHandler = app.get(ProductHandlerService)

await app.register(fastifyConnectPlugin, {
routes: router => {
productHandler.register(router)
},
})

const port = process.env.PORT ?? 4000
await app.listen(port)
}
bootstrap()

This code initializes the NestJS server running on the Fastify platform with a configuration that supports the HTTP/2 protocol. At the same time, it registers a Hook (onRequest) to log the HTTP version as well as the content-type of each incoming request and integrates the fastifyConnectPlugin plugin to activate and route the gRPC services of ProductHandlerService to the server.


After successfully starting the project, you can check the logs on NestJS to see the request information as follows:

httpVersion: 2.0 | contentType: application/proto


NextJS project

Please install the following packages:

yarn add @apollo/server @apollo/client @as-integrations/next
yarn add -D @graphql-codegen/cli @graphql-codegen/client-preset


Create file codegen.ts

import type {CodegenConfig} from '@graphql-codegen/cli'

const config: CodegenConfig = {
schema: 'http://localhost:3000/api/graphql',
documents: ['app/**/*.{ts,tsx}'],
generates: {
'./gql/': {
preset: 'client',
plugins: [],
presetConfig: {
gqlTagName: 'gql',
},
},
},
ignoreNoDocuments: true,
}

export default config

This file configures the GraphQL Codegen tool, which scans the entire frontend source code in the app directory to find GraphQL queries (gql). After that, it will automatically generate the corresponding data types and hooks in the ./gql/ directory based on the schema downloaded from the /api/graphql endpoint, helping to ensure data safety when working with GraphQL.


Create file provider/apolo-client.provider.tsx

'use client'

import {ApolloClient, HttpLink, InMemoryCache} from '@apollo/client'
import {ApolloProvider} from '@apollo/client/react'

const client = new ApolloClient({
link: new HttpLink({
uri: '/api/graphql',
includeExtensions: process.env.NEXT_PUBLIC_ENV === 'dev',
}),
cache: new InMemoryCache(),
})

export function ApolloClientProvider({children}: {children: React.ReactNode}) {
return <ApolloProvider client={client}>{children}</ApolloProvider>
}

This file is responsible for initializing an Apollo Client instance to manage GraphQL API calls and data caching on the Client side. Next, it provides the ApolloClientProvider component acting as a wrapper, helping all child components within the application to use Apollo's hooks to interact with GraphQL.

The includeExtensions field use to check to attach extensions info to request payload


Update file app/layout.tsx to add the Apollo provider before using:

import {ApolloClientProvider} from '@/provider/apolo-client.provider'
import {ReactQueryProviders} from '@/provider/react-query.provider'
import {AntdRegistry} from '@ant-design/nextjs-registry'
import type {Metadata} from 'next'

export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className="min-h-full font-sans">
<main>
<ApolloClientProvider>
<AntdRegistry>{children}</AntdRegistry>
</ApolloClientProvider>
</main>
</body>
</html>
)
}


Create file app/_lib/product-client.ts

import {ProductService} from '@/gen/product_pb'
import {createClient} from '@connectrpc/connect'
import {createConnectTransport} from '@connectrpc/connect-node'

export const productClient = createClient(
ProductService,
createConnectTransport({
baseUrl: process.env.SERVER_HOST || '',
httpVersion: '2',
})
)


Create file app/Product/product.hook.ts

import {gql} from '@/gql'
import {useMutation, useQuery} from '@apollo/client/react'

const GET_PRODUCTS = gql(`
query GetProducts {
getProducts {
id
name
price
}
}
`)

const ADD_PRODUCT = gql(`
mutation AddProduct($name: String!, $price: Float!) {
addProduct(name: $name, price: $price) {
id
}
}
`)

const UPDATE_PRODUCT = gql(`
mutation UpdateProduct($id: Int!, $name: String!, $price: Float!) {
updateProduct(id: $id, name: $name, price: $price) {
id
}
}
`)

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

export function useProductOperations() {
const {data, loading, refetch} = useQuery(GET_PRODUCTS)

const [addProduct, {loading: isAdding}] = useMutation(ADD_PRODUCT)
const [updateProduct, {loading: isUpdating}] = useMutation(UPDATE_PRODUCT)
const [deleteProduct, {loading: isDeleting}] = useMutation(DELETE_PRODUCT)

type ProductType = NonNullable<
NonNullable<typeof data>['getProducts']
>[number]

return {
products: data?.getProducts || [],
isLoading: loading || isAdding || isUpdating || isDeleting,
refetch,
addProduct,
updateProduct,
deleteProduct,
}
}

This code sets up a custom hook named useProductOperations. It predefines the queries (Query) and data modifications (Mutation) for the Product entity, then encapsulates the aggregated loading state along with the API calling functions (addProduct, updateProduct, deleteProduct, refetch) into a single place for UI components to easily reuse.


Before you can use these hooks, you need to run the following command to generate the type files for GraphQL:

$ yarn codegen
yarn run v1.22.22
$ graphql-codegen
Parse Configuration
Generate outputs
Done in 0.73s.


The result will be generated as follows:


Create file app/Product/page.tsx

'use client'

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

type ProductItemType = ReturnType<
typeof useProductOperations
>['products'][number]

export function ProductList() {
const {
products,
isLoading,
refetch,
addProduct,
updateProduct,
deleteProduct,
} = useProductOperations()

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

const openModal = (record?: ProductItemType) => {
setEditingId(record ? record.id! : null)
form.setFieldsValue(record || {name: '', price: 0})
setIsModalOpen(true)
}

const handleSave = async () => {
const values = await form.validateFields()
try {
if (editingId) {
await updateProduct({
variables: {id: editingId, name: values.name, price: values.price},
})
message.success('Updated successfully')
} else {
await addProduct({variables: {name: values.name, price: values.price}})
message.success('Added successfully')
}
setIsModalOpen(false)
refetch()
} catch (error) {
message.error('Operation failed')
}
}

const handleDelete = async (id: number) => {
try {
await deleteProduct({variables: {id}})
message.success('Deleted successfully')
refetch()
} catch (error) {
message.error('Delete failed')
}
}

const columns = [
{title: 'ID', dataIndex: 'id', width: 80},
{title: 'Name', dataIndex: 'name', className: 'font-medium'},
{
title: 'Price',
dataIndex: 'price',
render: (p: number) => `$${p.toFixed(2)}`,
},
{
title: 'Action',
key: 'action',
render: (_: any, record: ProductItemType) => (
<Space size="middle">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => openModal(record)}
>
Edit
</Button>

<Popconfirm
title="Sure to delete?"
onConfirm={() => handleDelete(record?.id!)}
okText="Yes"
cancelText="No"
>
<Button type="link" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
},
]

return (
<div className="p-10 bg-slate-50 min-h-screen">
<div className="max-w-5xl mx-auto bg-white p-8 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-extrabold text-slate-800">
Products Management
</h1>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => openModal()}
>
Create Product
</Button>
</div>

<Table
dataSource={products}
columns={columns}
loading={isLoading}
rowKey="id"
/>

<Modal
title={editingId ? 'Edit Product' : 'New Product'}
open={isModalOpen}
onOk={handleSave}
onCancel={() => setIsModalOpen(false)}
okText="Save"
confirmLoading={isLoading}
>
<Form form={form} layout="vertical" className="pt-4">
<Form.Item
name="name"
label="Product Name"
rules={[{required: true}]}
>
<Input placeholder="Enter product name" />
</Form.Item>
<Form.Item name="price" label="Price" rules={[{required: true}]}>
<InputNumber className="w-full" prefix="$" placeholder="0.00" />
</Form.Item>
</Form>
</Modal>
</div>
</div>
)
}

This is the user interface Component file (ProductList) using the Ant Design library to build the product CRUD functions. This component consumes data and manipulation functions from the useProductOperations hook, displays the product list in a table format (Table), manages the add/edit form via a pop-up window (Modal) and performs deletion confirmation through Popconfirm.


The result is as follows:





You also use Apollo Server to check GraphQL

Conclusion

Thus, we have completed implementing the following model: Client connects to NextJS using GraphQL, NextJS acting as middleware will connect to the NestJS server using gRPC.

As you can see, implementing just a simple function requires quite a few setup steps from client to server, the purpose of which is to meet strict requirements regarding security, performance and high scalability in large-scale enterprise projects.

If you do not have to implement projects like this, you can choose simpler solutions such as using a standard Restful API for easier deployment.

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