Merwyn Carrillos
Published on

A simple gRPC prototype with Go and NextJS

Introduction and Motivation

As I approach a decade of solving problems with Python, I find myself eager to explore the world of compiled programming languages. Yes, I've had to endure some JavaScript and TypeScript - even during the creation of this blog, though I consider those more a necessary evil than a personal decision.

My plan is to take a small step down from the peaks of Python's high-level elegance into (what appears to be) the simple, yet performant nature of Golang.

As someone who has self-learned all of what I know, I dare yet not dive into the depths of C, Zig, and Rust. We will get there, but we must walk before we run.

So back to what I know, what better way to learn a new language than with a prototype - an MVP?

Here's what I'm thinking:

  • A frontend in NextJS to display dynamic data.
  • A backend in Go to fulfill my business logic.
  • gRPC with protobuf to link them together.

Simple enough, yet complex enough to learn some Go and a few extra bits of tech along the journey.

In this code-along blog you'll create a simple, yet scalable web application architecture.


Table of Contents

But why not just REST?

REST is the communications method I am most familiar with. However, for no better reason than to stress myself out while learning something new, I've chosen to fulfill backend calls with gRPC.

If you're unfamiliar with the technology, I cover it below.

But first, a primer on Application Programming Interfaces (APIs).

A Primer on APIs

For the uninitiated, APIs themselves enable communication across systems and services. A set of rules and protocols that, together, facilitate the interaction between systems.

A simple analogy: A bartender at a bar is a real-world metaphor for an API.

You may or may not know what goes into a Cucumber Mojito, but you sure can ask a bartender for one. Sure enough, the bartender receives the request, does some work, and returns with the Mojito, ready for you to enjoy.

In this metaphor, the bartender is the API.

You don't need to know the details of how the drink is made; you just need to know how to ask a bartender for the drink.

In computers, REST and RPC (amongst other technologies) are sets of rules and principles that guide the design and creation of these APIs.

REST

Representational State Transfer (REST)

If you've heard of GET, POST, PUT, PATCH, DELETE methods, you're probably already familiar with REST - an architectural style for designing networked applications.

While REST is not a standard, websites using RESTful APIs are said to be following RESTful principles. This is because REST is a set of guidelines rather than a strict standard.

However, here's a simple example of how a simple REST implementation works:

Say you're on a library website which is using REST calls to deliver data to the site. You land on the homepage, are presented with a multitude of book covers which are available to rent, and you want to get details on a book on the homepage.

You click on any book title, and:

  1. The site will send a GET request using REST to ask its backend servers for details on that book. ISBN, Author, Title, etc.
  2. The GET request is received by the server, which processes it and responds with the requested data.
  3. At that point, since REST is stateless, each request is independent, and any further interactions will require new requests to be made.

To summarize, data is requested, a service responds, and all this happens over easy-to-read text, JSON most likely.

That seems basic enough if the action when you click that button is to return only that specific information.

Say instead of clicking on that book brings you to a new page. That page contains the previous information, but also details on the author, their other works, similar books from that same genre, etc.

A developer following the principle of separation-of-concerns will likely build distinct API endpoints to handle different features or data, so each new feature, such as author details or related books, may require a separate API request.

This is where REST can become cumbersome, even inefficient, as it requires multiple requests to fulfill all the data needed to display a single page.

RPC

A comical RPC illustration featuring a user requesting images of cats from a web service.

From the Wikipedia article on RPC:

In distributed computing, a remote procedure call (RPC) is when a computer program causes a procedure (subroutine) to execute in a different address space (commonly on another computer on a shared computer network), which is written as if it were a normal (local) procedure call, without the programmer explicitly writing the details for the remote interaction.

In a much simpler explanation, RPC is a protocol that allows a program to call functions or procedures on a different machine, abstracting the network details.

In a practical sense, say you have a client that needs to retrieve data from a database. This could be a simple web application that has a button for a user to retrieve pictures of cats.

We would write a function that queries the database and returns the data to the client.

We write the business logic in a function on a different machine, and the client would call that function as if it were local, and the pictures of cats would be returned.

We still need to write the function on the service side, but the client doesn't need to know the details of how the function is executed or how the data is retrieved. It just needs to know the function name, its location, and the data it expects, and the types of inputs and outputs.

It's almost like magic to the client.

An Example with Code

Real basic-like, working work RPC looks like:

Client (The caller):

# rpc_client.py

# Define RPC
server = rpc_library.channel("loadbalancer.pizza.com:8888")
order = server.place_order(size="Large", toppings=["cheese", "pepperoni"])
print(order)

Server (Fulfills the caller):

# rpc_service.py

def __start_oven(temp: int) -> bool:
    """Internal function to backend"""
    print(f'Oven set to {temp} degrees!')
    return true

def place_order(size: str, toppings: list[str])
    """Place order into other building/fulfillment services local to this codespace."""
    __start_oven(temp: 400)
    ...
    return f'Order placed for a {size} pizza with {toppings=}!'

For all intents and purposes, the client is just calling an internal function. It thinks server.place_order is local to its code space. The server is doing all the heavy lifting, and the client is none the wiser.

gRPC

From the gRPC website:

gRPC is a modern open source high performance Remote Procedure Call (RPC) framework that can run in any environment. It can efficiently connect services in and across data centers with pluggable support for load balancing, tracing, health checking and authentication. It is also applicable in last mile of distributed computing to connect devices, mobile applications and browsers to backend services.

"g" Remote Procedure Call (gRPC) is a communications framework for building interfaces between different services, and one of many adaptations of RPC. So like RPC, it allows you to call methods on a remote server as if they were local function calls.

Some key differences, and advantages over standard RPC are:

  • Use of Protocol Buffers (protobuf) instead of text-based formats like JSON
  • Code auto-generation
  • HTTP/2

Also similar to RPC, a popular use case for gRPC is communications between distributed systems and micro/services architectures.

I won't dive into all the benefit of gRPC over RPC - however a solid place to start is Google's own blog.

Protobuf

Not a framework, library, standard, or protocol - Protocol buffers (protobuf) is a versatile, language agnostic, data serialization format.

The format is flexible, extensible, and used for data transmission across systems and programming languages.

Working together with gRPC, protobuf is used to define the structure of the data (the messages), while gRPC uses it to define the service methods to be used across systems.

With protobuf, a schema is defined using .proto files which are used to generate code from those definitions (using support libraries).

When used with gRPC, protobuf is used in lieu of text-based formats like JSON or XML. Protobuf is most often serialized into binary data, but if needed, it can also be serialized into text-based formats like JSON.

Above all, it's the combination of gRPC and protobuf that makes them a compelling alternative to REST services.

Alternatives

Another popular choice for cross-service communication is GraphQL. But why not GraphQL?

Compared to REST and gRPC, GraphQL has a significantly steeper learning curve and is more complex to implement.

GraphQL's flexible query language can be a double-edged sword. While it offers a high-level customization, GraphQL requires careful management to prevent end-users from taking advantage of its endpoints.

For this project, I'm looking for a simple, scalable, and efficient solution - GraphQL kind of over-complicates that.

Enough theory for now, let's get on with it.


Tools and Setup

So let's build a simple prototype to get started with gRPC.

We want a basic website, with a button to call some data and display it on the page. That button will trigger a gRPC call that is fulfilled by Go by retrieving data from some database.

We're end up with something like this:

A high level workflow of the functional prototype.

At a high level we will:

  1. Create a NextJS frontend
  2. Create a Go backend
  3. Create a gRPC contract
  4. Generate Go and JavaScript code from the contract
  5. Implement the gRPC client in NextJS
  6. Implement the gRPC server in Go
  7. Serve data from the backend to the frontend via gRPC.

For simplicity's sake, a .csv file will take the place of our database, which we'll display on the website - though we'll at least implement a strategy pattern to make our code at least extensible.

Project Layout

Our starting folder hierarchy looks like:

grpc_next_go_starter/
└── grpc_library/
    ├── backend/
    ├── frontend/
    └── proto/

You can build it with this command:

mkdir -p grpc_next_go_starter/grpc_library/{backend,frontend,proto}

Some notes on these folders:

  • The root level folder grpc_next_go_starter/ will contain all the code for this example, and what we will refer to as the project root.
  • grpc_library/ is the name of our application and will contain the frontend, backend, and proto folders.
  • backend/ will contain the Go code for the gRPC server.
  • frontend/ contains NextJS code for the web application.
  • proto/ stores the gRPC contract that will be used by both the frontend and backend.

As you read through this article, we'll fill in each folder, create new files, and install necessary dependencies.

Installation

Steps

  1. Install all core software components
  2. Runtimes: go, node
  3. Frameworks: nextjs, grpc
  4. Install supporting software components:
  5. Runtime support libraries: protoc, grpc-js,proto-loader
  6. Configure our protobuf definitions
  7. Configure the frontend
  8. Configure the backend
  9. Run and Test

Unless specifically mentioned, commands will work for both platforms. However, I'll include installation methods for both a Linux (Debian - though I'll reference as debian/linux) machine as well as macOS computer when commands need to diverge.

  • For Linux I'll use the apt package manager.
  • For macOS I'll run through the installations with (homebrew)[https://brew.sh/].

Core Runtimes

We'll need to first install the core software runtimes, NodeJS and Golang, to our system.

Both NodeJS and Go are cross-platform, so the installation process should be similar on both macOS and Linux. They also both have package managers, npm and go get, respectively, which we will use to install dependencies.

Lastly, they each have large communities and extensive documentation, so you should be able to find help quite easily if you run into any issues. Altogether, they are a good choice for this type of project.

NodeJS

NodeJS is a JavaScript runtime.

Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.

Node itself changes quite a bit so we are going to be installing the Node Version Manager, nvm, to manage node versions.

Run the following command in a terminal.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

Now install the latest LTS version:

nvm install --lts=Jod

Here I chose the Jod release (v22.13.1, minor version at the time of writing), however you can list available LTS releases with nvm ls-remote --lts

This will install a couple things. Node itself, as well as the Node Package Manager, npm which we will use to install dependencies.

We can verify with

node --version
npm --version

Go

Go is a statically typed, compiled programming language which emphasizes simplicity, efficiency, and reliability.

In our project, Go will be responsible for business logic fulfillment, the gRPC server, and talking to supporting services like databases and our filesystem.

Run the following commands in a terminal to install Go:

# macOS
brew install golang

# debian/linux
apt update && apt install golang-go

Next, navigate to the backend/ folder. We'll run a command to give us a starting point for this backend.

go mod init grpc_library_backend

This will create a single file in our backend/ directory titled go.mod

Add Go Package Binaries to Path

When a system attempts to run a binary from the terminal by name, it will look through all folders specified in its $PATH variable. You can see what that variable holds by running echo $PATH on a terminal.

There's a chance the Go installation did not set its binary folder in your PATH variable.

To check and add it if necessary, run the following script in your terminal:

GOPATH=$(go env GOPATH)

# Check if GOPATH is in PATH
if [[ ":$PATH:" != *":$GOPATH/bin:"* ]]; then
  echo "GOPATH not found in PATH. Adding it to .bashrc."

  # Check if GOPATH is already defined in .bashrc
  if ! grep -q 'export GOPATH' ~/.bashrc; then
    echo 'export GOPATH=$(go env GOPATH)' >> ~/.bashrc
  fi

  echo 'export PATH=$PATH:$GOPATH/bin' >> ~/.bashrc
  source ~/.bashrc

  echo "GOPATH added to PATH and .bashrc sourced."
else
  echo "GOPATH is already in PATH."
fi

Essential Frameworks

Our key frameworks for this project are NextJS and gRPC.

NextJS

We are going to use NextJS, a React-based web framework, to run our frontend.

I like NextJS for its simplicity and ease of deployment, but any other React-based web framework could be used here instead.

In reality, you could use any frontend framework you like, or even just vanilla JavaScript, but for the sake of this article, we'll stick with NextJS.

We'll start with a bare-bones installation using a NextJS example template.

To install, navigate to the frontend/ directory and run:

npx create-next-app . --example hello-world

This command will install a minimal NextJS website - a single page that renders the message "Hello, World".

If you're curious to see what the page looks like, you can run the command:

npm run dev

And navigate to localhost:3000 to view in your browser.

Pretty sweet, we have a basic webpage. You can kill the process with Ctrl+C at the terminal.

Checking the project's root directory again and you'll see that you have a few extra files in your frontend/ folder.

The folder structure might look something like this:

grpc_next_go_starter/
└── grpc_library/
    ├── backend
    │   └── go.mod
    ├── frontend
    │   ├── README.md
    │   ├── app
    │   │   ├── layout.tsx
    │   │   └── page.tsx
    │   ├── next-env.d.ts
    │   ├── next.config.ts
    │   ├── package-lock.json
    │   ├── package.json
    │   └── tsconfig.json
    └── proto

gRPC Libraries

gRPC permeates both the client and server side of the API, so we'll need to install the necessary libraries for both.

We'll start with the frontend.

Frontend Libraries

We'll install the gRPC client for NodeJS and a utility for loading .proto files.

If you're still in the frontend/ folder, great. If not, navigate to the frontend/ directory and run:

npm install --save-dev @grpc/grpc-js @grpc/proto-loader

Notes on this command:

  • The --save-dev flag tells npm to install the packages as development dependencies, which means they will not be included in package.json automatically.
  • @grpc/grpc-js is the gRPC client for NodeJS.
  • @grpc/proto-loader is a utility for loading .proto files.

Next, navigate up a level and into the backend/ directory.

Backend Libraries

In the backend/ directory, if you haven't already initialized the directory for go (you'll find a go.mod file if so) run the command:

go mod init grpc_library_backend

If you have, you can proceed with the following commands:

go get google.golang.org/grpc
go get google.golang.org/protobuf/proto
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

We're installing a few packages here:

  • grpc - the gRPC library for Go
  • protobuf/proto - the protobuf library for Go
  • protoc-gen-go - the Go protocol buffer compiler plugin
  • protoc-gen-go-grpc - the Go gRPC plugin for the protocol buffer compiler

Go doesn't need any additional libraries to load .proto files, as the protoc-gen-go and protoc-gen-go-grpc plugins will generate the necessary code for us.

This will automatically create a go.sum file which contains an exact version of the package installed (including dependencies), as well as a checksum of that package. Simultaneously, the commands will populate the existing go.mod file with packages required for this backend service.

Lastly, verify the installation of our system level packages with:

protoc-gen-go --version
protoc-gen-go-grpc --version

Protobuf Compiler

We'll also need to install the protobuf compiler, protoc, to generate the necessary code for our gRPC service.

Run the following commands in your terminal:

# macOS
brew install protobuf

# debian/linux
apt install protobuf-compiler -y

Remember to use sudo if you're not running as root (you most likely won't be running as root).

To verify the installation, run:

protoc --version

OK, so that's our core software and key runtimes installed.

We're now ready to configure our frameworks, create .proto definitions, and generate our gRPC code.

Configuration

NextJS App

Navigate to the frontend/ directory. Here, we will build out our front-end.

As the point is to have a mechanism to pull data from a backed, we'll want a button, but also a way to display the data.

We'll first start with updating the homepage.

Update the homepage

The homepage is the first page a user will see when they visit the site. In this example, we'll have a very basic, minimal homepage that will display a button, that when pressed, will show a list of book metadata.

Step 1: update the tsconfig.json file to include the following lines in the compilerOptions key:

  "baseUrl": ".",
  "paths": {"@/*": ["*"]},

On my end, that file ends up looking like:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["*"] },
    ...
  }
}

Step 2 Delete the file: app/page.js

Step 3 Create a new file page.tsx, in a new app/(site) folder.

So if in the frontend/ directory, you could do this:

mkdir "app/(site)"
touch "app/(site)/page.tsx"

And add the following code:

// app/(site)/page.tsx

import GetBooks from '@/components/Books'

const HomePage = () => {
  return (
    <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
      <h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '20px' }}>
        Home - My Books
      </h1>
      <GetBooks />
    </div>
  )
}

export default HomePage

Step 4 Create a new file, data/books.json:

This step will be temporary, but will set us up for later use.

Essentially, what we're doing is going to test the frontend logic to ensure we can at least retrieve data locally, before we unleash gRPC.

If in the frontend/ directory, you could do this:

mkdir -p "public/data"
touch "public/data/books.json"

Populate the file with this data:

[
  {
    "name": "Dune",
    "author": "Frank Herbert",
    "format": "Hardcover",
    "year": 1965,
    "isbn": "9780441013593",
    "description": "A science fiction novel about a desert planet and its political intrigue.",
    "genre": "Science Fiction"
  },
  {
    "name": "1984",
    "author": "George Orwell",
    "format": "Paperback",
    "year": 1949,
    "isbn": "9780451524935",
    "description": "A dystopian novel exploring the dangers of totalitarianism.",
    "genre": "Dystopian"
  },
  {
    "name": "The Hobbit",
    "author": "J.R.R. Tolkien",
    "format": "Hardcover",
    "year": 1937,
    "isbn": "9780547928227",
    "description": "A fantasy novel about a hobbit's journey to reclaim treasure from a dragon.",
    "genre": "Fantasy"
  },
  {
    "name": "Ender's Game",
    "author": "Orson Scott Card",
    "format": "E-book",
    "year": 1985,
    "isbn": "9780812550702",
    "description": "A young boy is trained to become a commander in the fight against an alien race.",
    "genre": "Science Fiction"
  }
]

Step 5 Create the Books component.

We will use a Next.js Client Component to fetch the data from the books.json file and display it on the page.

WHile we can stick everything in a single file, I think it's easier if we split it up into multiple.

Step 5.1 A new file, components/FetchButton.tsx

This file will create a button that upon press will fetch books.

In the frontend/ directory, create a new file:

mkdir "components"
touch "components/FetchButton.tsx"

And populate it with this data:

// components/FetchButton.tsx

'use client';

interface ButtonProps {
  onClick: () => void;
  loading: boolean;
}

const Button = ({ onClick, loading }: ButtonProps) => (
  <button
    onClick={onClick}
    style={{
      padding: '10px 20px',
      backgroundColor: '#4CAF50',
      color: 'white',
      border: 'none',
      borderRadius: '5px',
      cursor: 'pointer',
      marginBottom: '20px',
      transition: 'background-color 0.3s',
    }}
    disabled={loading}
  >
    {loading ? 'Retrieving Books...' : 'Fetch Books'}
  </button>
);

export default Button;

Step 5.2 A new file, components/BookTable.tsx

This file contains the code for the creation of a Table which will display the books data.

In the frontend/ directory, you could do this:

mkdir "components"
touch "components/BookTable.tsx"

And populate it with this data:

// components/BookTable.tsx

'use client';

interface Book {
  [key: string]: string;
}

interface BookTableProps {
  books: Book[];
}

const BookTable = ({ books }: BookTableProps) => {
  const tableStyle = {
    width: '100%',
    borderCollapse: 'collapse' as 'collapse',
    border: '1px solid lightgray',
  };

  const tableHeaderStyle = {
    backgroundColor: '#f1f1f1',
    padding: '10px',
    textAlign: 'left' as 'left',
    border: '1px solid lightgray',
  };

  const tableRowStyle = {
    borderTop: '1px solid lightgray',
    transition: 'background-color 0.3s',
  };

  const tableRowHoverStyle = {
    backgroundColor: '#f9f9f9',
  };

  const cellStyle = {
    padding: '10px',
    border: '1px solid lightgray',
  };

  const headers = books.length > 0 ? Object.keys(books[0]) : [];

  return (
    <div style={{ overflowX: 'auto' }}>
      <table style={tableStyle}>
        <thead>
          <tr>
            {headers.map((header, index) => (
              <th key={index} style={tableHeaderStyle}>
                {header.charAt(0).toUpperCase() + header.slice(1)}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {books.map((book, index) => (
            <tr key={index} style={{ ...tableRowStyle, ...(index % 2 === 0 ? tableRowHoverStyle : {}) }}>
              {headers.map((header, idx) => (
                <td key={idx} style={cellStyle}>
                  {book[header]}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default BookTable;

There's a bit in here, but most of it is styling for the table.

Step 5.3 A new file, components/Books.tsx

We need a file to pull it all together. This file will hold logic to render a button, fetch data, and eventually display on the page.

In the frontend/ directory, create a new file:

mkdir "components"
touch "components/Books.tsx"

And populate it with this data:

'use client';

import Button from './FetchButton';
import BookTable from './BookTable';
import useFetchBooks from '../hooks/useFetchBooks';

const GetBooks = () => {
  const { books, loading, error, fetchBooks } = useFetchBooks();

  return (
    <div style={{ backgroundColor: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)' }}>
      <Button onClick={fetchBooks} loading={loading} />

      {loading && <p style={{ color: 'gray' }}>Loading books...</p>}

      {error && <p style={{ color: 'red' }}>Error: {error}</p>}

      {books.length > 0 ? (
        <BookTable books={books} />
      ) : (
        !loading && <p style={{ color: 'gray' }}>No books available.</p>
      )}
    </div>
  );
};

export default GetBooks;

Step 5.4 A new file, hooks/useFetchBooks.tsx

Finally, we want to decouple the data fetching mechanism from the display logic.

In the frontend/ directory, create a new file:

mkdir "hooks"
touch "hooks/useFetchBooks.tsx"

And populate it with this data:

import { useState } from 'react';

interface Book {
  [key: string]: string;
}

const useFetchBooks = () => {
  const [books, setBooks] = useState<Book[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  const fetchBooks = async () => {
    setLoading(true);
    setError(null); // Reset any previous error
    try {
      const response = await fetch('/data/books.json');
      if (!response.ok) {
        throw new Error('Failed to fetch books');
      }
      const data = await response.json();
      setBooks(data); // Set the fetched data into the state
    } catch (error: any) {
      setError('Error fetching books');
    } finally {
      setLoading(false);
    }
  };

  return { books, loading, error, fetchBooks };
};

export default useFetchBooks;

Step 5 Recap

That's a lot of files, but a lot of it is styling. Separating the files makes it easier to manage and maintain, but requires repeating a few things.

Overall, it just looks more daunting than it really is.

We're setting up a button to fetch books and displaying them in a table.

Using the useState hook, we're setting up a state for books, loading, and error messages. When the button is clicked, we set the loading state to true, reset any previous errors, and try to fetch the books from the hooks file we created.

If the fetch is successful, we set the books state to the static data we've defined. There's even a loading... message while the data is being fetched.

If there's an error, we set the error state to the error message.

After all that, we render the books in a table.

Basic Frontend Recap To recap, we now have a folder structure that looks like:

grpc_library
├── backend
│   ├── go.mod
│   └── go.sum
├── frontend
│   ├── README.md
│   ├── app
│   │   ├── (site)
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── components
│   │   ├── BookTable.tsx
│   │   ├── Books.tsx
│   │   └── Button.tsx
│   ├── hooks
│   │   └── useFetchBooks.ts
│   ├── next-env.d.ts
│   ├── next.config.ts
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   │   └── data
│   │       └── books.json
│   └── tsconfig.json
└── proto

You can run this again with npm run dev and navigate to localhost:3000 to see the changes.

You'll see a more styled UI now, with a button to fetch books and display them in a table.

But we are not done yet. All the book data is being pulled by NextJS itself. We need to set up the backend to fulfill the request for books.

We'll need setup a separate backend for that.

gRPC and Protobuf

gRPC along with protobuf give us flexibility to split this growing system into smaller services which we can maintain individually.

We have the frontend doing frontend client component stuff, and the backend looking to jump into the mix.

We'll make calls to a backend service and that service will feed us data.

What makes that possible is protobuf and we'll start by creating a simple contract to serve as the foundation of our communication.

Contracts and .proto files

At the project root, the grpc_library folder, we'll have a proto/ folder we have yet to touch.

We will create the gRPC contract here, and will reference the definition of our API with supporting tools we've already installed to generate our Go and JavaScript code.

Create a file in this folder named books_service.proto and fill it with the following:

syntax = "proto3";

option go_package = "backend/pb"

package books;

// The Book message contains the book information
message Book {
    string name = 1;
    string author = 2;
    string format = 3;
    int32 year = 4;
    string isbn = 5;
    string description = 6;
    string genre = 7;
}

// Request and Response messages for Books
message BooksRequest {}

message BooksResponse {
    repeated Book books = 1;
}

// The BooksService provides the GetBooks function
service BooksService {
    rpc GetBooks (BooksRequest) returns (BooksResponse);
}

The magic of protobuf lies in that we can define the structure of our data in a .proto file and then use that file to generate code for both the frontend and backend.

In this file, we define a Book message that contains the information for a single book, kind of like a prototype. This defines the structure of the book data we expect to communicate to ond fro.

The message BooksRequest {} and message BooksResponse {} lines are used to define the request and response messages for the GetBooks function.

Lastly, a lot happens in the service BooksService {} block. This is where we define the service methods that will be used by the client to interact with the server. In this case, we have a single method, GetBooks, which takes a BooksRequest and returns a BooksResponse.

This altogether defines the contract for our gRPC service, and the protoc compiler will use this file to generate the necessary code for both the frontend and backend.

Generate Go and JavaScript code

Now let's generate the appropriate Go and JavaScript files.

Go Code

In the proto/ directory run the following command::

protoc --go_out=.. --go-grpc_out=.. books_service.proto

This will create a new folder structure within the backend/ folder:

├── backend
│   ├── go.mod
│   ├── go.sum
│   └── pb
│       ├── books_service_grpc.pb.go
│       └── books_service.pb.go
...

JavaScript Code

In the root directory, frontend/, run the following command:

npx proto-loader-gen-types --longs=String --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --outDir=pb ../proto/*.proto

Go Backend

Moving into the my_library/backend/ directory, we'll see only a couple files: go.mod and go.sum. Those are the files we created when we initialized the backend directory.

We'll need to create a few more files to get the backend up and running. Create a new file main.go

// main.go
package main

import (
    "context"
    "encoding/json"
    "log"
    "net"
    "os"

    pb "grpc_library_backend/pb"
    "google.golang.org/grpc"
)

const (
    port     = ":8080"
    dataFile = "data/book_library.json"
)

type server struct {
    pb.UnimplementedBooksServiceServer
}

// GetBooks handles the request to fetch books
func (s *server) GetBooks(ctx context.Context, req *pb.BooksRequest) (*pb.BooksResponse, error) {
    log.Println("Received GetBooks request") // Log when a request is received

    // Open the JSON file
    file, err := os.Open(dataFile)
    if err != nil {
        log.Printf("Failed to open data file: %v", err) // Log if there's an error opening the file
        return nil, err
    }
    defer file.Close()

    // Decode the JSON data into a slice of books
    var books []pb.Book
    decoder := json.NewDecoder(file)
    if err := decoder.Decode(&books); err != nil {
        log.Printf("Failed to decode JSON data: %v", err) // Log if there's an error decoding the JSON data
        return nil, err
    }

    // Convert to a slice of pointers to pb.Book
    bookPointers := make([]*pb.Book, len(books))
    for i := range books {
        bookPointers[i] = &books[i]
    }

    log.Printf("Successfully retrieved %d books", len(books)) // Log the number of books retrieved

    log.Println("Successfully processed GetBooks request") // Log when processing is successful

    // Return the response
    return &pb.BooksResponse{Books: bookPointers}, nil
}

func main() {
    // Listen on the specified port
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }

    // Create a new gRPC server
    s := grpc.NewServer()

    // Register the BooksService with the gRPC server
    pb.RegisterBooksServiceServer(s, &server{})

    // Log and start the server
    log.Printf("Server is running on port %s", port)
    if err := s.Serve(lis); err != nil {
        log.Fatalf("Failed to serve: %v", err)
    }
}

This file is kind of it. The main.go file is the entry point for our Go application.

We define a server struct that implements the BooksServiceServer interface. This struct has a single method, GetBooks, which reads a JSON file and returns the book data.

The main function sets up the gRPC server, registers the BooksService with the server, and starts the server on port 8080.

We also define a dataFile constant that points to the location of the JSON file containing the book data.

Data Loading

Ideally you'd want to connect this Go server to a live database. However, this article is getting a little long.

Instead, we want a JSON file that holds the data, similar to what we have in our temporary frontend, except this is going to be "permanent".

Create a folder in the backend/ folder called data and copy over the file that's in our frontend/public/data/books.json

If you're currently in the 'backend/' folder, you can run this:

cp -r ../frontend/public/data/ .

That will copy over the data folder from the frontend/ folder and bring it into the backend/ folder for go to serve. We should now have a folder structure that looks like:

├── backend
│   ├── data
│   │   └── books.json
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── pb
│       ├── books_service_grpc.pb.go
│       └── books_service.pb.go
...

Frontend Updates to Handle gRPC Calls

Frontend API Handler

API routes are handled by the Route Handler system for NextJS. We will have a single API route, /api/books that will be used to return our query back to the browser requesting the data.

In the frontend/ directory, create a new folder app/api/books and a new file route.js:

mkdir -p app/api/books
touch app/api/books/route.js

Edit the route.js file to look like:

// api/books/route.js
import { NextResponse } from 'next/server'
import { join } from 'path'

const grpc = require('@grpc/grpc-js')
const protoLoader = require('@grpc/proto-loader')
const PROTO_PATH = join(process.cwd(), '../proto/books_service.proto')

const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
})

const proto = grpc.loadPackageDefinition(packageDefinition).books
const client = new proto.BooksService('localhost:8080', grpc.credentials.createInsecure())

const fetchData = async () => {
  return new Promise((resolve, reject) => {
    client.GetBooks({}, (err, response) => {
      if (err) {
        reject(err)
      } else {
        resolve(response)
      }
    })
  })
}

export async function GET(req) {
  try {
    const data = await fetchData()
    const books = data.books

    console.log("Books data received from gRPC server:", books) // Log the books data

    return NextResponse.json({ books }) // Return only the books data
  } catch (err) {
    console.error("Error fetching data from gRPC server:", err) // Log the error
    return new NextResponse(
      JSON.stringify({ error: err.message }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    )
  }
}


We'll also log when books are received.

Run with the API Route

Lastly, we want to call the API route when clicking the "Get Books" button.

Because we compartmentalized that functionality within its own file, a hook, we just need to update that file to call the API route.

We will update one line,

const response = await fetch('/data/books.json'); // Update to fetch from your API route

to look like:

const response = await fetch('/api/books'); // Update to fetch from your API route

So we tell it to no longer fetch from a local file, rather use the API we just created.

Finally, open a new terminal window (we need to run two at a time here) and run:

npm run dev

You can verify the frontend is trying to fetch from the backend as you'll see something like this in the terminal after clicking the "Fetch Books" button.

 ✓ Compiled /api/books in 194ms
 ⨯ [RangeError: Invalid status code: 0] {
  code: 'ERR_HTTP_INVALID_STATUS_CODE'
}
 ⨯ [RangeError: Invalid status code: 0] {
  code: 'ERR_HTTP_INVALID_STATUS_CODE'
}
 GET /api/books 500 in 280ms

To resolve, start the backend.

In the backend\ folder, run:

go run main.go

Navigate to localhost:3000 and click the "Fetch Books" button to view the data!

That's it, we're done!

The ending folder structure is going to look something like this:

grpc_library
├── backend
│   ├── data
│   │   └── book_library.json
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── pb
│       ├── books_service.pb.go
│       └── books_service_grpc.pb.go
├── frontend
│   ├── README.md
│   ├── app
│   │   ├── (site)
│   │   │   └── page.tsx
│   │   ├── api
│   │   │   └── books
│   │   │       └── route.js
│   │   └── layout.tsx
│   ├── components
│   │   ├── BookTable.tsx
│   │   ├── Books.tsx
│   │   └── Button.tsx
│   ├── hooks
│   │   └── useFetchBooks.ts
│   ├── next-env.d.ts
│   ├── next.config.ts
│   ├── package-lock.json
│   ├── package.json
│   ├── pb
│   │   ├── books
│   │   │   ├── Book.ts
│   │   │   ├── BooksRequest.ts
│   │   │   ├── BooksResponse.ts
│   │   │   └── BooksService.ts
│   │   └── books_service.ts
│   ├── public
│   │   └── data
│   │       └── books.json
│   └── tsconfig.json
└── proto
    └── books_service.proto

Conclusion

If you've followed along, you should now have a working NextJS frontend that can query a Go backend using gRPC.

This is a REALLY simple example, but it should give you a good starting point to create bigger and better protobuf contracts.