API Design Best Practices: REST, RPC, and Beyond
A practical guide to designing APIs that scale: REST resource modeling, HTTP semantics, gRPC and protocol buffers, and when GraphQL, JSON-RPC, or tRPC fit better, with runnable FastAPI and gRPC examples.
Published on • August 12, 2026
AI Assistant

Every team eventually builds an API, and the difference between a joy to consume and a constant source of tickets is almost never the framework you pick. It is the set of conventions you follow: plural nouns instead of verbs, status codes clients can branch on, contracts that are discoverable, and a versioning story that does not break the web. This post is a code-first tour of API design best practices across the two big families — resource-oriented REST and operation-oriented RPC — plus the modern alternatives (GraphQL, JSON-RPC, tRPC) and when each genuinely wins. Key technologies covered: HTTP and status codes, OpenAPI, Protocol Buffers, gRPC, GraphQL, and tRPC.
Prerequisites
- Basic understanding of HTTP requests and responses; the MDN HTTP docs are my reference below.
- Python 3.10+ with
fastapi,uvicorn,grpcio, andgrpcio-toolsfor the runnable examples, plusprotocto generate gRPC code.
REST: Design Resources, Not Endpoints
REST is resource-oriented: the URL names a noun, the HTTP method is the verb, and the server returns a representation. Follow these rules and your API becomes predictable enough that developers guess the next endpoint without reading docs.
1. Nouns, Plural Collections, Shallow Nesting
# Good — nouns in URLs, methods express the action
GET /v1/users
POST /v1/users
GET /v1/users/{id}
PATCH /v1/users/{id}
DELETE /v1/users/{id}
# Bad — verbs leak into URLs
GET /getUser?id=123
POST /createUser
POST /deleteUser?id=123
Nest only true parent-child relationships and keep it to two levels. GET /users/{id}/addresses is fine; GET /orgs/{o}/users/{u}/orders/{i}/items is a nightmare. Use kebab-case for multi-word resources (/blog-posts) and reserve query parameters for filtering, sorting, and sparse fields: GET /orders?status=pending&sort=-total&fields=id,total.
2. Status Codes Are Part of the Contract
Clients branch on status codes, so use the narrowest code that fits: 200 for reads, 201 Created when POST creates a resource (with a Location header), 204 for deletes. Errors: 400 malformed, 401 unauthenticated, 403 not allowed, 404 missing, 429 rate-limited — per the MDN status code reference. Never return 200 for an error.
3. Versioning
Version from day one. URL path versioning (/v1/users) is the safest, most transparent default; header (Accept: application/vnd.example.v1+json) and query-param versioning are alternatives but easier to get wrong. Whatever you choose, treat every version as immutable — add a /v2 rather than mutating /v1.
4. Pagination
Collections need pagination. Offset (?offset=0&limit=20) is fine for small, stable datasets; cursor pagination is right for large, frequently-changing ones. Return an opaque token so clients never guess page numbers:
{ "data": [{ "id": 9, "title": "Atomic Habits" }], "next_cursor": "caret0" }
5. HATEOAS: A Spectrum, Not a Binary
HATEOAS means responses carry links describing the next actions: {"id": 42, "status": "shipped", "links": {"cancel": "/v1/orders/42/cancel"}}. A full hypermedia engine is overkill for most teams; the pragmatic middle ground is clean resources plus an OpenAPI document for discoverability, adding links only where workflows genuinely branch.
RPC: gRPC, Protocol Buffers, and Streaming
Where REST models data, RPC models actions. gRPC is a high-performance RPC framework that runs on HTTP/2 and uses Protocol Buffers as both its IDL and wire format. Define a service once in a .proto file and protoc generates type-safe clients and servers in many languages:
syntax = "proto3";
package books;
service BookService {
rpc GetBook (BookRequest) returns (Book);
rpc ListBooks (Empty) returns (stream Book);
}
message BookRequest { string id = 1; }
message Empty {}
message Book {
string id = 1;
string title = 2;
string author = 3;
}
The stream keyword unlocks gRPC’s biggest advantage. Per the gRPC core concepts docs, there are four call types: unary, server streaming, client streaming, and bidirectional. Protobuf’s binary encoding keeps payloads small, HTTP/2 multiplexes many calls over one connection, and the strict schema means a client never silently drifts from the server. Trade-offs: browsers cannot call raw gRPC (you need gRPC-Web or a transcoding gateway), and binary payloads are harder to debug than JSON.
Modern Alternatives
- GraphQL: a single endpoint where clients query exactly the shape they need. Great when many client teams with different data needs hit one complex domain, and introspection makes the schema self-documenting. Cost: server-side complexity and weaker HTTP caching. See the GraphQL learn section.
- JSON-RPC 2.0: a lightweight, transport-agnostic protocol — requests are objects with
method,params, andid, supporting notifications and batches. This is why MCP and many agent/tool protocols use it: human-readable, dead simple, a natural fit for action-shaped workflows. See the JSON-RPC 2.0 specification. - tRPC: end-to-end typesafe RPC for TypeScript: write plain functions on the server, and the client gets inferred types with zero code generation or schema duplication — the compiler is the contract. Ideal for TypeScript monorepos. Start with the tRPC quickstart.
Choosing: A Comparison Table
| Dimension | REST | gRPC | GraphQL | tRPC | JSON-RPC |
|---|---|---|---|---|---|
| Style | Resources | Actions | Query language | Actions | Actions |
| Wire format | JSON | Protobuf (binary) | JSON | JSON | JSON |
| Contract | OpenAPI (optional) | .proto (mandatory) | Schema (SDL) | TS types | None |
| Streaming | Limited | Native, bidirectional | Subscriptions | Subscriptions | Notifications/batches |
| Browser support | Native | Needs proxy | Native | Native | Native |
| Best for | Public, cacheable APIs | Internal microservices | Flexible client queries | TS monorepos | Agent/tool protocols |
Putting It All Together
The most common production pattern in 2026 is hybrid: REST + OpenAPI for the public, browser-facing surface, gRPC for internal service-to-service calls. Here is a minimal FastAPI REST endpoint that generates OpenAPI docs automatically, plus a gRPC service smoke test.
FastAPI REST endpoint with OpenAPI
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Books API", version="1.0")
class Book(BaseModel):
id: int
title: str
author: str
BOOKS: list[Book] = []
@app.get("/v1/books", response_model=list[Book])
def list_books():
return BOOKS
@app.post("/v1/books", response_model=Book, status_code=201)
def create_book(book: Book):
new_book = Book(id=len(BOOKS) + 1, **book.model_dump())
BOOKS.append(new_book)
return new_book
@app.get("/v1/books/{book_id}", response_model=Book)
def get_book(book_id: int):
book = next((b for b in BOOKS if b.id == book_id), None)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return book
Run it with uvicorn main:app --reload and open http://localhost:8000/docs — interactive OpenAPI documentation for free. Best practice in action: the code is the contract, always in sync.
gRPC service smoke test
Generate stubs, then run this client against a server on port 50051:
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. books.proto
# smoke_test.py
import grpc
import books_pb2
import books_pb2_grpc
channel = grpc.insecure_channel("localhost:50051")
client = books_pb2_grpc.BookServiceStub(channel)
reply = client.GetBook(books_pb2.BookRequest(id="1"))
print(f"Book: {reply.title}")
for book in client.ListBooks(books_pb2.Empty()):
print(f"Streamed: {book.title}")
Run python smoke_test.py; it prints Book: Atomic Habits and streams Streamed: Deep Work.
Conclusion & Next Steps
There is no single best API style — there is a best fit per boundary. REST with OpenAPI remains the right default for public, cacheable APIs; gRPC wins for high-throughput, streaming, strongly-typed internal services; and GraphQL, JSON-RPC, or tRPC cover the gaps when query flexibility, tooling, or end-to-end type safety matter more. Whatever you choose, the best practices are the same: consistent naming, correct status codes, versioning from day one, pagination on every collection, and a machine-readable contract.
Next, dive into the OpenAPI Specification repository, the gRPC introduction, or the MDN guides on caching and conditional requests to make your endpoints fast and correct.