← Back to the section

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/search with a JSON body.
  • Versioning — how to build /api/v1 and 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.