Type-Safe APIs with React and Go

The magic of Protocol Buffers and gRPC
Sep 4th 2025

I love React, Go and end-to-end type-safety. How can we make this work?

It's simple. We just need to use protocol buffers and gRPC.

These are the steps:

  1. Describe the API in a .proto file.
  2. Generate code using the protoc compiler.
  3. Use the generated code.

Let's do an example together.

We'll create a Go API and a SPA with Next.js using static exports. I prefer to use Next.js even for SPAs because of the developer experience and its APIs.

Make sure to have Node.js, Go and the protoc compiler installed.

We'll start scaffolding the frontend, backend, and creating the directories where the generated code will live.

Frontend Setup

terminal
mkdir type-safe-go-react
cd type-safe-go-react
mkdir frontend
npx create-next-app@latest frontend --empty --disable-git

Accept all default options for the create-next-app command.

Enable static export by setting the output mode inside next.config.ts:

frontend/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;

Check that everything works:

terminal
cd frontend
npm run dev

Open your browser. Go to localhost:3000. You should see Hello world!.

Now we need to install the plugin that the protoc compiler will use to generate TypeScript code:

terminal
npm i ts-proto

Create the api directory in src/lib. We will point the compiler to this directory later.

terminal
mkdir -p src/lib/api

Backend Setup

At the root directory run:

terminal
mkdir backend
cd backend
go mod init github.com/YOUR-GITHUB-USERNAME/type-safe-go-react

Yes. In Go the module name should be an URL. I don't make the rules.

Create a main.go with a hello world program.

terminal
touch main.go
backend/main.go
package main

func main() {
	println("Hello world!")
}

Check that it works:

terminal
 go run main.go
Hello world!

Cool. Now create the directory for the api package:

terminal
mkdir api

Your setup should look like this:

terminal
 tree -L 4 -I node_modules .
.
├── backend
│   ├── api
│   ├── go.mod
│   └── main.go
└── frontend
    ├── eslint.config.mjs
    ├── next-env.d.ts
    ├── next.config.ts
    ├── package-lock.json
    ├── package.json
    ├── postcss.config.mjs
    ├── README.md
    ├── src
    │   ├── app
    │   │   ├── globals.css
    │   │   ├── layout.tsx
    │   │   └── page.tsx
    │   └── lib
    │       └── api
    └── tsconfig.json

8 directories, 13 files

Proto Setup

We'll be mocking a job queue service. The API will have two methods: SubmitJob and StreamJobStatus. The first method will receive a job type and return a jobId. The second, will return a real-time stream of the status of a given job.

Create a job-queue.proto file at the root directory:

terminal
touch job-queue.proto

And add this:

job-queue.proto
syntax = "proto3";

package api;

option go_package = "github.com/YOUR-GITHUB-USERNAME/type-safe-go-react/api";

enum JobType {
    encode = 0;
    decode = 1;
}

message SubmitJobRequest {
 JobType type = 1;
}

message SubmitJobResponse {
    string job_id = 1;
    JobType type = 2;
}

message JobStatusRequest {
    string job_id = 1;
}

message JobStatusResponse {
    string job_id = 1;
    string status = 2;
    int32 progress = 3;
}

service JobQueue {
    rpc SubmitJob(SubmitJobRequest) returns (SubmitJobResponse);
    rpc StreamJobStatus(JobStatusRequest) returns (stream JobStatusResponse);
}

If you've never seen one of these, it can be confusing. But it's pretty simple. Let's start at the bottom.

We declare a JobQueue service with two rpc methods. As you can see we have to type the request and the response for each. Notice how in the return type of the StreamJobStatus method we add stream.

The streaming response is similar to server-sent events. Currently bi-directional streaming is not supported for client to server communication.

Above that, we define the different message types. Each field has a type, name, and a unique number, used for serialization.

We also have a JobType enum with our two job types: encode and decode.

At the very top, we have our file configuration: syntax = "proto3" declares we're using protocol buffers version 3, package api; creates a namespace to avoid conflicts, and the go_package option tells the compiler where to generate Go code.

Don't forget to use your GitHub account on go_package. It must match the module name.

Compiling

Now we have a .proto file with our API and the frontend and backend scaffolding. We need to compile it and place the generated code in the corresponding directories.

To make things easier to run, create a Makefile

terminal
touch Makefile

Here we will setup the compile command:

Makefile
.PHONY: compile

compile:
	protoc job-queue.proto \
		--go_out=./backend/api \
		--go-grpc_out=./backend/api \
		--go_opt=paths=source_relative \
		--go-grpc_opt=paths=source_relative \
		--plugin=./frontend/node_modules/.bin/protoc-gen-ts_proto \
		--ts_proto_out=./frontend/src/lib/api \
		--ts_proto_opt=esModuleInterop=true,importSuffix=.js,outputClientImpl=grpc-web

The compile command calls protoc with our .proto file.

The first 4 flags tell protoc where to place the go output.

The --plugin flag points to the ts-proto binary in the frontend's node_modules.

--ts_proto_out sets the out directory for the TypeScript code.

The --ts_proto_opt flag sets options esModuleInterop=true and importSuffix=.js. We need them to be able to work with our current tsconfig.json and ESM modules. outputClientImpl=grpc-web is needed to run the code in the browser.

Let's see if it works!

terminal
make compile

It you don't get any errors, congratulations! You are one step closer of the bliss of type-safety.

Let's check the generated code:

frontend/src/lib/api/job-queue.ts
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
//   protoc-gen-ts_proto  v2.7.7
//   protoc               v6.32.0
// source: job-queue.proto

/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { grpc } from "@improbable-eng/grpc-web";
import { BrowserHeaders } from "browser-headers";
import { Observable } from "rxjs";
import { share } from "rxjs/operators";

export const protobufPackage = "api";

// Rest...

As you can see the code got generated by protoc-get-ts_proto. Follow the comment and do not edit the file!

But we have a problem. We are importing a lot of packages that we don't have installed yet. Let's fix that. Navigate into /frontend and run:

terminal
npm i rxjs @improbable-eng/grpc-web browser-headers

Great. Now let's go the backend and see what's going in there.

In backend/api you'll find two files: job-queue_grpc.pb.go and job-queue.pb.go. The first contains the gRPC server and client implementation. The second contains the actual types and serialization methods.

backend/api/job-queue_grpc.pb.go
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc             v6.32.0
// source: job-queue.proto

package api

import (
	context "context"
	grpc "google.golang.org/grpc"
	codes "google.golang.org/grpc/codes"
	status "google.golang.org/grpc/status"
)

// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9

// Rest...
backend/api/job-queue.pb.go
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// 	protoc-gen-go v1.36.8
// 	protoc        v6.32.0
// source: job-queue.proto

package api

import (
	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
	reflect "reflect"
	sync "sync"
	unsafe "unsafe"
)

const (
	// Verify that this generated code is sufficiently up-to-date.
	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
	// Verify that runtime/protoimpl is sufficiently up-to-date.
	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)

// Rest..

Both of them have the same problem as the frontend generated code. We are importing stuff that we haven't installed. In Go this is easier to do. We just need to run go mod tidy:

terminal
 go mod tidy
go: finding module for package google.golang.org/grpc
go: finding module for package google.golang.org/grpc/codes
go: finding module for package google.golang.org/protobuf/runtime/protoimpl
go: finding module for package google.golang.org/protobuf/reflect/protoreflect
go: finding module for package google.golang.org/grpc/status
go: downloading google.golang.org/protobuf v1.36.8
go: downloading google.golang.org/grpc v1.75.0
go: found google.golang.org/grpc in google.golang.org/grpc v1.75.0
go: found google.golang.org/grpc/codes in google.golang.org/grpc v1.75.0
go: found google.golang.org/grpc/status in google.golang.org/grpc v1.75.0
go: found google.golang.org/protobuf/reflect/protoreflect in google.golang.org/protobuf v1.36.8
go: found google.golang.org/protobuf/runtime/protoimpl in google.golang.org/protobuf v1.36.8
go: downloading google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7
go: downloading golang.org/x/net v0.41.0
go: downloading go.opentelemetry.io/otel/sdk/metric v1.37.0
go: downloading go.opentelemetry.io/otel v1.37.0
go: downloading go.opentelemetry.io/otel/sdk v1.37.0
go: downloading go.opentelemetry.io/otel/metric v1.37.0
go: downloading go.opentelemetry.io/otel/trace v1.37.0
go: downloading gonum.org/v1/gonum v0.16.0
go: downloading github.com/go-logr/logr v1.4.3

Exposing the API

Let's move on from the little hello world program in the main.go file and expose the gRPC API:

backend/main.go
package main

import (
	"net"

	"github.com/dynamic-calm/type-safe-go-react/api"
	"google.golang.org/grpc"
)

// Embed UnimplementedJobQueueServer to satisfy the interface
type jobQueueServer struct {
	api.UnimplementedJobQueueServer
}

const port = "8080"

func main() {
	grpcSrv := grpc.NewServer()
	jobQueueSrv := &jobQueueServer{}
	api.RegisterJobQueueServer(grpcSrv, jobQueueSrv)
	listener, err := net.Listen("tcp", "127.0.0.1:"+port)
	if err != nil {
		panic(err)
	}

	println("Listening on port: " + port)
	grpcSrv.Serve(listener)
}

Here we are creating a new gRPC server, instantiating our jobQueueServer and registering it.

Notice that we are embedding the api.UnimplementedJobQueueServer struct in jobQueueServer. That creates an implementation of our API methods that return a gRPC codes.Unimplemented status code.

To see if it works, we can run the server and do a request with Postman:

terminal
 go run main.go
Listening on port: 8080

Create a new gRPC request and set the URL to localhost:8080. Then import the proto file from the codebase:

Now we have access the to the methods:

If you try with one you should see the Unimplemented code in the response:

Perfect. Let's implement the methods:

backend/main.go
package main

import (
	"context"
	"net"
	"time"

	"github.com/dynamic-calm/type-safe-go-react/api"
	"google.golang.org/grpc"
)

// Now we embed the real implementation struct
type jobQueueServer struct {
	api.JobQueueServer
}

const port = "8080"

func main() {
 // Same as before...
}

func (jqs *jobQueueServer) SubmitJob(
	ctx context.Context,
	request *api.SubmitJobRequest,
) (*api.SubmitJobResponse, error) {
	time.Sleep(2 * time.Second)
	return &api.SubmitJobResponse{JobId: "id-1", Type: request.Type}, nil
}


func (jqs *jobQueueServer) StreamJobStatus(
	req *api.JobStatusRequest,
	streamer grpc.ServerStreamingServer[api.JobStatusResponse],
) error {
	status := "running"
	for i := range 10 {
		if i == 9 {
			status = "completed"
		}

		time.Sleep(500 * time.Millisecond)
		streamer.Send(&api.JobStatusResponse{
			JobId:    req.JobId,
			Status:   status,
			Progress: int32(i+1) * 10,
		})
	}
	return nil
}

First we need to embed api.JobQueueServer in jobQueueServer.

Then we implement the SubmitJob and StreamJobStatus methods on our own jobQueueServer struct type.

For SubmitJob we just sleep for 2 seconds to simulate work, and return a hardcoded id alongside the job type.

For StreamJobStatus we loop and send 10 responses to the stream. We wait half a second in between each.

Let's try again in Postman:

Beautiful.

But we have one problem. We can't call this methods from the browser. To fix that we need to wrap our server with grpcweb:

backend/main.go
package main

import (
	"context"
	"net/http"
	"time"

	"github.com/dynamic-calm/type-safe-go-react/api"
	"github.com/improbable-eng/grpc-web/go/grpcweb"
	"google.golang.org/grpc"
)

type jobQueueServer struct {
	api.JobQueueServer
}

const port = "8080"

func main() {
	grpcSrv := grpc.NewServer()
	jobQueueSrv := &jobQueueServer{}
	api.RegisterJobQueueServer(grpcSrv, jobQueueSrv)
	wrappedGrpc := grpcweb.WrapServer(
		grpcSrv,
		grpcweb.WithOriginFunc(func(origin string) bool {
			// Allow all origins, DO NOT do this in production
			return true
		}),
	)
	println("Listening on port: " + port)
	http.ListenAndServe(":"+port, wrappedGrpc)
}

As you can see we got rid of the TCP listener. We now call the default http.ListenAndServe method passing the wrappedGrpc as the handler.

Since we are importing the grpcweb package, we need to install it.

terminal
 go mod tidy
go: finding module for package github.com/improbable-eng/grpc-web/go/grpcweb
go: found github.com/improbable-eng/grpc-web/go/grpcweb in github.com/improbable-eng/grpc-web v0.15.0

Calling the API from the frontend

A plain HTML site would work, but let's add a touch of polish:

frontend/src/app/layout.tsx
import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google";

import "./globals.css";

export const metadata: Metadata = {
  title: "gRPC <3",
  description: "Type-safe Go + React",
};

const jetBrainsMono = JetBrains_Mono({
  subsets: ["latin"],
});

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className={jetBrainsMono.className}>
      <body className="h-dvh bg-neutral-950 text-xs text-neutral-50">
        {children}
      </body>
    </html>
  );
}

At the root layout we center the contents, add colors and a mono font.

On the page.tsx we just need to add two buttons. When clicked they will call each of the methods of the API.

frontend/src/app/page.tsx
import { type ComponentProps } from "react";

export default function Home() {
  return (
    <main className="flex h-full items-center justify-center">
      <div className="flex max-w-80 flex-col gap-2">
        <Button>Submit job</Button>
        <Button>Stream Job Status</Button>
      </div>
    </main>
  );
}

function Button({ children, ...props }: ComponentProps<"button">) {
  return (
    <button
      className="w-full cursor-pointer border border-neutral-800 bg-neutral-900 px-5 py-1 text-neutral-400 uppercase transition-colors duration-150 hover:bg-neutral-800 hover:active:bg-neutral-900"
      {...props}
    >
      {children}
    </button>
  );
}

It looks something like this:

Calling the methods

We need to create the gRPC client. Create a client.ts file in frontend/src/lib/api

frontend/src/lib/api/client.ts
import { GrpcWebImpl, JobQueueClientImpl } from "@/lib/api/job-queue";

const grpc = new GrpcWebImpl("http://localhost:8080", { debug: true });
const jobQueueClient = new JobQueueClientImpl(grpc);

export default jobQueueClient;

Now we can use the jobQueueClient to call the methods. We will set the result in the state and replace the text of the button with the results.

frontend/src/app/page.tsx
"use client";

import { useState, type ComponentProps } from "react";

import {
  JobType,
  type SubmitJobResponse,
  type JobStatusResponse,
} from "@/lib/api/job-queue";

import jobQueueClient from "@/lib/api/client";

export default function Home() {
  const [job, setJob] = useState<SubmitJobResponse | null>(null);
  const [statuses, setStatuses] = useState<JobStatusResponse[]>([]);

  async function handleSubmitJob() {
    if (job) {
      setJob(null);
      return;
    }

    const response = await jobQueueClient.SubmitJob({ type: JobType.encode });
    setJob(response);
  }

  function handleStreamJobStatus() {
    if (statuses.length > 0) {
      setStatuses([]);
      return;
    }

    const jobId = job?.jobId ?? "some-id";
    const observable = jobQueueClient.StreamJobStatus({ jobId });

    observable.subscribe((status) => {
      setStatuses((prev) => [...prev, status]);
    });
  }

  const latestProgress = statuses.at(-1)?.progress;

  return (
    <main className="flex h-full items-center justify-center">
      <div className="flex w-full max-w-80 flex-col gap-2">
        <Button onClick={handleSubmitJob}>˙
          {job ? `Got job id: ${job.jobId}` : "Submit job"}
        </Button>
        <Button onClick={handleStreamJobStatus}>
          {statuses.length > 0 ? `${latestProgress}%` : "Stream Job Status"}
        </Button>
      </div>
    </main>
  );
}

Look at that beauty.

We import the types from the generated code at @/lib/api/job-queue. Then we create a handler for each button. The only thing to notice here is that the gRPC stream gets translated into an observable in the client. We push every event into the state and that's it!

This is the result:

Ideally we would use something like TanStack Query to handle the loading and error states. But I'll leave that up to you for homework.

Wrapping up

As you can see everything is type-safe. From the enum to the methods. It's so satisfying to use the correct type in the useState.

And the best part is that if you need to do a change to the API you change it on the .proto file, compile it again and you'll have the new types everywhere.

This is a very bare-bones setup. A little toy. There is no error handling, loading states, cancellation, etc. But it shows that is not that hard to create a fully type-safe full-stack apps with React and Go.

I hope you learned something.

Check the full source code here.