Posts List
moustapha.dev : Exploring a better way of building full stack web app blog post cover image

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

types.ts
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 :

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-proto

The standard is to put proto files in versionned pkg-like directory levels

mkdir -p proto/aecsar/go_proto/v1

Here 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

buf.yaml
version: v2
deps:
  - buf.build/bufbuild/protovalidate
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
buf.gen.yaml
version: 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/protovalidate

Configuration 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 generate

The only thing left do to is implement our backend as we would do in a normal go project.

cmd/server/main.go
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

frontend/src/lib/buf-client.ts
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

App.svelte
<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.

Exploring a better way of building full stack web app — Moustapha Ndoye