Communication Overview
The frontend uses two protocols to communicate with the backend, depending on the rendering context.
gRPC-Web
Used by client components. Binary protocol through Envoy proxy. Efficient for real-time updates and streaming.
REST API
Used by server components and API routes. JSON over HTTP via gRPC-Gateway. Required for SSR data fetching.
gRPC-Web (Client Components)
Client components use Connect-RPC with generated TypeScript clients.
Setting up the transport
// lib/grpc-transport.ts
import { createGrpcWebTransport } from "@connectrpc/connect-web"
export const transport = createGrpcWebTransport({
baseUrl: "http://localhost:8081", // Envoy gRPC-Web port
})Making a request
// In a client component
"use client"
import { createPromiseClient } from "@connectrpc/connect"
import { AccountingService } from "@/gen/accounting_connect"
import { transport } from "@/lib/grpc-transport"
const client = createPromiseClient(AccountingService, transport)
// Make the call
const response = await client.listTransactions({
tenantId: workspaceId,
pageSize: 50,
pageToken: "",
})With React Query
// Using TanStack Query for caching
import { useQuery } from "@tanstack/react-query"
function TransactionList({ tenantId }: { tenantId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["transactions", tenantId],
queryFn: () => client.listTransactions({ tenantId, pageSize: 50 }),
})
if (isLoading) return <Loading />
if (error) return <Error error={error} />
return <Table data={data.transactions} />
}REST API (Server Components)
Server components use the REST client for data fetching during SSR.
REST client usage
// In a server component or API route
import { rest } from "@/lib/rest-client"
// The client automatically includes auth token from cookies
const data = await rest.get<TransactionListResponse>(
`/v1/tenants/${tenantId}/transactions?page_size=50`
)
// POST request
const created = await rest.post<Transaction>(
`/v1/tenants/${tenantId}/transactions`,
{ description: "New transaction", amount: 1000 }
)REST endpoints
gRPC-Gateway generates REST endpoints from proto files. The mapping follows these patterns:
# Transactions
GET /v1/tenants/{tenant_id}/transactions
POST /v1/tenants/{tenant_id}/transactions
GET /v1/tenants/{tenant_id}/transactions/{id}
PATCH /v1/tenants/{tenant_id}/transactions/{id}
DELETE /v1/tenants/{tenant_id}/transactions/{id}
# Dashboard
GET /v1/tenants/{tenant_id}/dashboard/stats
GET /v1/tenants/{tenant_id}/dashboard/widgets
# Categories
GET /v1/tenants/{tenant_id}/categories
POST /v1/tenants/{tenant_id}/categoriesURL Parameter Naming
REST uses snake_case for query parameters (page_size), while gRPC uses camelCase (pageSize). The rest-client helper handles this conversion automatically.
Proto Files
API contracts are defined in Protocol Buffer files.
Proto file locations
proto/
├── accounting.proto # Dashboard, reports, journals
├── organization.proto # Orgs, workspaces, teams
├── payment_v3.proto # Transactions
├── tax.proto # Tax calculations
├── captable.proto # Cap table (equity)
├── forensics.proto # Document verification
├── invoice_extraction.proto # OCR extraction
└── buf.gen.yaml # Code generation configGenerated code locations
# Go server code
backend/proto/*.pb.go
backend/proto/*.pb.gw.go # REST gateway
# TypeScript clients
frontend/src/gen/*_pb.ts
frontend/src/gen/*_connect.tsRegenerating Proto Code
After modifying any .proto file:
make protoThis runs buf generate and creates both Go and TypeScript code.
Example proto definition
// payment_v3.proto
service PaymentV3Service {
rpc ListTransactions(ListTransactionsRequest) returns (ListTransactionsResponse) {
option (google.api.http) = {
get: "/v1/tenants/{tenant_id}/transactions"
};
}
rpc CreateTransaction(CreateTransactionRequest) returns (Transaction) {
option (google.api.http) = {
post: "/v1/tenants/{tenant_id}/transactions"
body: "*"
};
}
}
message Transaction {
string id = 1;
string tenant_id = 2;
string description = 3;
int64 amount = 4; // In cents
string currency = 5;
TransactionType type = 6;
// ...
}Authentication Flow
Every request passes through authentication middleware.
JWT Token Structure
{
"user_id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"is_super_admin": false,
"organizations": [
{ "organization_id": "uuid", "role": "owner" }
],
"workspaces": [
{ "workspace_id": "uuid", "workspace_name": "My Business", "role": "admin" }
],
"exp": 1234567890,
"iat": 1234567800
}Including Auth Token
The token is stored in an HTTP-only cookie and included automatically:
// Client-side: token sent via cookie
// The browser automatically includes cookies
// Server-side: REST client reads from cookies
import { cookies } from "next/headers"
const token = cookies().get("access_token")?.valueBackend Auth Context
// In a gRPC handler
func (s *Server) ListTransactions(ctx context.Context, req *pb.Request) (*pb.Response, error) {
// Get auth context (injected by AuthInterceptor)
authCtx, ok := middleware.GetAuthContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "not authenticated")
}
// Check tenant access
if !authCtx.HasTenantAccess(req.TenantId) {
return nil, status.Error(codes.PermissionDenied, "no access to tenant")
}
// Proceed with request...
}Error Handling
gRPC status codes are mapped to HTTP status codes for REST responses.
Status code mapping
| gRPC Code | HTTP Status | Meaning |
|---|---|---|
| OK (0) | 200 | Success |
| INVALID_ARGUMENT (3) | 400 | Bad request |
| NOT_FOUND (5) | 404 | Resource not found |
| ALREADY_EXISTS (6) | 409 | Conflict |
| PERMISSION_DENIED (7) | 403 | Forbidden |
| RESOURCE_EXHAUSTED (8) | 429 | Rate limited |
| UNAUTHENTICATED (16) | 401 | Not logged in |
| INTERNAL (13) | 500 | Server error |
Frontend error handling
// Handling gRPC errors
import { ConnectError, Code } from "@connectrpc/connect"
try {
await client.createTransaction(request)
} catch (error) {
if (error instanceof ConnectError) {
switch (error.code) {
case Code.InvalidArgument:
toast.error("Invalid input: " + error.message)
break
case Code.PermissionDenied:
toast.error("You don't have permission")
break
case Code.ResourceExhausted:
toast.error("Rate limited. Try again later.")
break
default:
toast.error("Something went wrong")
}
}
}