
Exploring a better way of building full stack web app
March 15th 26
Since switching to Go as my backend language of choice, I've been thinking of a better way of building APIs. This weekend, I went to explore a stack that uses Go, Protobuf and ConnectRPC to build a type-safe API, consumed by a Svelte app. In this post, I'll be sharing the problem I'm facing, that led me to this, how it feels like working with the stack and what I choose moving forward.
Working with go is a very nice experience but going back to the frontend, there's a lot of friction and boilerplate when I comes to typing the API.
A simple approach may look like the following. I would define the types of each request and response first, in the frontend
export type BaseResponse = {
success: boolean;
message?: string;
error_code?: ErrorCode;
data?: {
validation_errors?: ValidationErrs;
};
};
export type GetOAuthURLResponse = BaseResponse & {
data: {
auth_url: string;
};
};
export type OAuthResponse = BaseResponse & {
data: {
access_token: string;
refresh_token: string;
};
};And typically, wrap API endpoints that need to be reusable in custom hooks. The issue with this approach is first types aren't enforced, the frontend can misspell fields, updating the types when backend changes is time-consuming and can be automated through code generation and finally, it's a bit long and repetitive as in a typical production app, you would have a lot of queries, wrapped in hooks. In the context of a team, it's also challenging in three ways : API discover and documentation for the frontend team and API backward compatibility.
These are the constraints :
- Go as the backend language
- Svelte, or any other JS framework as the frontend
- Type-safe APIs between the two
- Documentation in source code, not a separate tool or process
- Exportable docs for frontend devs, so they don't need to run the backend locally
- Open to any API format, not necessarily REST
- Simple to work with
What are the options
REST & OpenAPI (swaggo / huma)
The most obvious choice, but it fails the core constraint. Type safety is codegen-dependent. You generate TypeScript types from the spec, but there's nothing enforcing that the actual runtime response matches that spec. If a Go handler returns an unexpected field or the wrong type, the TypeScript types won't catch it. They'll just silently fail. You can harden this with runtime validators on both ends but that's just a bad idea for obvious reasons.
GraphQL
Fails for multiple reasons. GraphQL solves overfetching, which is a frontend-driven problem. It introduces a lot of complexity and it's pure overkill for a single frontend consumer. GraphQL would be relevant if multiple clients with different data needs hit the same API.
tRPC
Would give genuine end-to-end type safety, the server and client share the same type definitions directly, no codegen drift possible. But it's TypeScript-only. Go is out.
Raw gRPC
Genuine schema-enforced type safety via Protobuf, but native gRPC uses HTTP/2
with binary framing that the browser fetch API can't access at the low level
required. You need a proxy in front, which adds infrastructure overhead and a
new thing to operate, configure, and debug. Violates the simplicity constraint.
Why Protobuf & Buf & ConnectRPC is the perfect fit
Protobuf, one real source of truth
The .proto file is not documentation that describes your API. It is your
API. Both the Go server code and the TypeScript client code are mechanically
generated from it. A type mismatch between client and server isn't a runtime
surprise, it's a generation-time or compile-time error.
Buf, the missing DX layer
Raw protoc can be painful. Buf is a single CLI that handles linting, codegen,
dependency management, and breaking change detection. It also enforces best
practices on your proto files (naming conventions, versioning) that matter as a
schema evolves. Buf even provides an LSP and a lot of other
tools I'll talk about later.
ConnectRPC, the browser problem solved without infra
This is the piece that makes the whole stack viable. The Connect protocol is a
simpler HTTP-based protocol that carries the same RPC semantics as gRPC but
works natively in browsers over plain HTTP/1.1 or HTTP/2; no proxy, no extra
infrastructure. One Go server can speak Connect, gRPC, and gRPC-Web. The
browser gets JSON by default (readable in devtools), binary proto is opt-in for
performance. The TypeScript ConnectRPC client generated by Buf is just typed
async functions that use fetch, by default, under the hood.
A simple project with this stack
Let's build a simple project using this stack. The goal is to have a simple
greeting service on our backend, that receives a name and sends back Hello, name. The full code can be found on GitHub. Let's first
install Buf and
initialize a Go project and a Svelte vite app. Then create a proto/ directory.
mkdir go-fullstack-proto
cd go-fullstack-proto
mkdir backend
cd backend && go mod init github.com/aecsar/go-full-protoThe standard is to put proto files in versionned pkg-like directory levels
mkdir -p proto/aecsar/go_proto/v1Here is the protobuf definition of our simple greeting service. If you're not familiar with protobuf, head over to protobuf.dev for a quick tutorial.
syntax = "proto3";
package aecsar.go_proto.v1;
import "buf/validate/validate.proto";
message GreetRequest {
string name = 1 [(buf.validate.field).string = {
min_len: 1
max_len: 50
}];
}
message GreetResponse {
string greeting = 1;
}
service GreetService {
rpc Greet(GreetRequest) returns (GreetResponse) {}
}It's pretty straightforward. I'm using validate from buf for input validation.
Let's now add buf's configuration files
version: v2
deps:
- buf.build/bufbuild/protovalidate
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILEversion: v2
clean: true
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.11
out: ./backend/gen/pb/
opt:
- paths=source_relative
- remote: buf.build/connectrpc/gosimple:v1.19.1
out: ./backend/gen/pb/
opt:
- paths=source_relative
- simple
- remote: buf.build/bufbuild/es:v2.11.0
out: ./frontend/src/gen/pb
include_imports: true
opt: target=ts
inputs:
- directory: ./proto/
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/aecsar/go-proto/gen/pb
disable:
- file_option: go_package
module: buf.build/bufbuild/protovalidateConfiguration documentation can be found here. Once we have our proto schema defined and configuration set, we can initialize the frontend and generate clients.
bun create vite@latest # frontend/ dir
buf dep update
buf generateThe only thing left do to is implement our backend as we would do in a normal go project.
package main
import (
"context"
"fmt"
"log"
"net/http"
"connectrpc.com/connect"
"connectrpc.com/validate"
pb "github.com/aecsar/go-proto/gen/pb/aecsar/go_proto/v1"
pbconnect "github.com/aecsar/go-proto/gen/pb/aecsar/go_proto/v1/go_protov1connect"
)
// This implements the generated pbconnect.GreetServiceHandler
// by defining the Greet method
type GreetServer struct{}
func (s *GreetServer) Greet(_ context.Context, req *pb.GreetRequest) (*pb.GreetResponse, error) {
res := &pb.GreetResponse{
Greeting: fmt.Sprintf("Hello, %s!", req.Name),
}
return res, nil
}
func main() {
greeter := &GreetServer{}
mux := http.NewServeMux()
path, handler := pbconnect.NewGreetServiceHandler(
greeter,
connect.WithInterceptors(validate.NewInterceptor()),
)
mux.Handle(path, withCORS(handler))
p := new(http.Protocols)
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true) // Use h2c so we can serve HTTP/2 without TLS.
srv := http.Server{
Addr: ":3000",
Handler: mux,
Protocols: p,
}
fmt.Println("server listening on port 3000")
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("error starting server : %v", err)
}
}And just like that, we have an rpc server. You can test it using curl
curl \
--header "Content-Type: application/json" \
--data '{"name": "Frederic"}' \
http://localhost:3000/aecsar.go_proto.v1.GreetService/Greet | jq
{
"greeting": "Hello, Frederic!"
}Let's use the buf generated client on our Svelte app
import { GreetService } from "@pb/greet_pb";
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
const transport = createConnectTransport({
baseUrl: "http://localhost:3000",
});
export const greetClient = createClient(GreetService, transport);And the UI
<script lang="ts">
import { greetClient } from "./lib/buf-client";
import type { GreetResponse } from "@pb/greet_pb";
let name = $state("");
let loading = $state(false);
let res = $state<GreetResponse | null>(null);
async function greet() {
loading = true;
try {
const r = await greetClient.greet({
name,
});
res = r;
} catch (e) {
console.log("error greeting: ", e);
} finally {
loading = false;
}
}
</script>
<section>
<div>
<input bind:value={name} name="name" class="input" placeholder="Name" />
<button class="button" onclick={greet}>
{#if loading}
Loading...
{:else}
Greet
{/if}
</button>
</div>
{#if res}
<div class="res">
{res.greeting}
</div>
{/if}
</section>Nice. greetClient is fully typed and sending and invalid request throws an error
error greeting: ConnectError: [invalid_argument] validation error:
- name: value length must be at least 1 characters [string.min_len]What's next
I really like working with this set of tools. It's clean, simple and end-to-end type-safe. I want to push it further and build more complex systems with it. I'll also experiment more with the Buf platform : remote schema registry and documentation generation from proto files. What's your favourite way of building type-safe APIs ? Please let me know.
Thanks for reading.