When a team builds an API, one developer writes /getOrders, another /orders/list, a third /Orders. All three do the same thing, but the client and colleagues have to guess. Let's go over the rules that make URLs predictable.
Why URLs Should Follow a Convention
Imagine walking into a store where every price tag is written differently: one in English, one capitalized, one with the word "get" at the front. It's unclear where to look for what.
A REST URL is the address of a resource, not a command. The address /orders says "orders live here". The HTTP method (GET, POST, DELETE) already explains what to do with them. That's why URLs don't need verbs like getOrders or createProduct.
URL Format: Lowercase Letters and Hyphens
The rule is simple: everything lowercase, words separated by a hyphen.
/orders ✓
/payment-methods ✓
/sales-orders ✓
/Orders ✗ (capital letter)
/paymentMethods ✗ (camelCase)
/payment_methods ✗ (underscore)
The hyphen is chosen because search engines and browsers correctly interpret it as a word separator. An underscore sometimes gets lost under a link in text and is indexed worse.
Trailing Slash in chi
By default, chi does not redirect /orders/ to /orders. If the code says r.Get("/orders", handler), a request to /orders/ returns 404. So just don't add a trailing slash to routes.
Collections and Single Resources
A collection is a noun in the plural:
/orders — all orders
/products — all products
/customers — all customers
The singular is used only for a "singleton" subresource inside a parent that can have only one of it:
/orders/{id}/summary — one summary for an order
/customers/{id}/profile — one customer profile
Don't confuse the two: /order (without s) for a collection is a common mistake. A collection is always plural.
Route Structure in chi
chi lets you group routes with r.Route. Here's a typical structure:
r := chi.NewRouter()
r.Get("/health", healthHandler) // service endpoint — outside /api/v1
r.Route("/api/v1", func(r chi.Router) {
r.Route("/orders", func(r chi.Router) {
r.Get("/", listOrders)
r.Post("/", createOrder)
r.Post("/search", searchOrders)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", getOrder)
r.Put("/", updateOrder)
r.Patch("/", patchOrder)
r.Delete("/", deleteOrder)
r.Post("/confirm", confirmOrder)
r.Post("/cancel", cancelOrder)
r.Route("/items", func(r chi.Router) {
r.Get("/", listOrderItems)
r.Post("/", addOrderItem)
})
})
})
r.Route("/products", func(r chi.Router) {
r.Get("/", listProducts)
r.Post("/", createProduct)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", getProduct)
r.Put("/", updateProduct)
r.Delete("/", deleteProduct)
})
})
})
Service endpoints (/health, /metrics, /ready) sit outside /api/v1 — they're not part of the business API.
Reading a Path Parameter
In a route, a parameter is marked with curly braces: {id}. Inside the handler you read it via chi.URLParam:
func getOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.GetOrder(r.Context(), id)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
When there are two different parameters in the path, give them unique names — {orderId} and {itemId}. If you name both {id}, chi returns the same value for both chi.URLParam(r, "id") calls, and that's hard to notice.
At Most Two Levels of Nesting
Nesting shows ownership: /orders/{id}/items — the line items of a specific order. But once you go three levels deep, the URL becomes long and fragile: if a resource ever moves, all clients break.
The rule: no more than two levels. If you need a third, promote the resource to the top level and pass the relationship through a query parameter:
// two levels — fine
r.Get("/orders/{id}/items", listOrderItems)
// three levels — too deep
// r.Get("/orders/{id}/items/{itemId}/shipments", ...)
// instead — a separate resource with a filter
r.Get("/shipments", listShipments) // ?itemId=...
func listShipments(w http.ResponseWriter, r *http.Request) {
itemID := r.URL.Query().Get("itemId")
// ...
}
Complex Filtering — POST /search
If a search needs many conditions — statuses, date ranges, several identifiers — the query string becomes awkward. In that case you make a separate route POST /resources/search with a JSON body:
r.Post("/orders/search", searchOrders)
type SearchOrdersRequest struct {
CustomerID string `json:"customerId"`
Statuses []string `json:"statuses"`
AmountFrom int64 `json:"amountFrom"`
AmountTo int64 `json:"amountTo"`
}
func searchOrders(w http.ResponseWriter, r *http.Request) {
var req SearchOrdersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
// ...
}
Common Mistakes
Verbs in the URL. /getOrders, /createOrder, /deleteProduct violate the REST principle. Use the HTTP method: GET /orders, POST /orders, DELETE /products/{id}.
Extension in the URL. /orders.json — the format is specified with the Accept: application/json header, not a path suffix.
Singular for collections. /order instead of /orders — the client can't tell whether it's one order or a list.
Three or more levels of nesting. /orders/{id}/items/{itemId}/shipments — promote shipments to the top level.
In Short
- URLs in lowercase, words separated by a hyphen:
/payment-methods,/sales-orders. - A collection is always plural:
/orders,/products. - A singleton subresource inside a parent is singular:
/orders/{id}/summary. - chi does not redirect a trailing slash — don't add one to routes.
- Service endpoints (
/health,/metrics) go outside/api/v1. - Nesting — at most two levels; promote the third level to the top with a filter.
- Two parameters in the path — give them unique names:
{orderId}and{itemId}. - Complex filtering —
POST /resources/searchwith a JSON body.
What to Read Next
- Versioning — how to build
/api/v1and manage backward compatibility. - Query Parameters — filtering, pagination, sorting.
- Aliases and Action Endpoints —
/me,/latest, non-standard actions. - JSON and Response Format — the structure of the response body.