Type-Safe APIs with React and Go
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:
- Describe the API in a
.proto
file. - Generate code using the
protoc
compiler. - 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
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
:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;
Check that everything works:
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:
npm i ts-proto
Create the api
directory in src/lib
. We will point the compiler to this directory later.
mkdir -p src/lib/api
Backend Setup
At the root directory run:
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.
touch main.go
package main
func main() {
println("Hello world!")
}
Check that it works:
➜ go run main.go
Hello world!
Cool. Now create the directory for the api
package:
mkdir api
Your setup should look like this:
➜ 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:
touch job-queue.proto
And add this:
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
touch Makefile
Here we will setup the compile
command:
.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!
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:
// 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:
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.
// 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...
// 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
:
➜ 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:
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:
➜ 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:
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
:
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.
➜ 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:
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.
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
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.
"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.