Pierwsze API, które zaprojektowałem w 2019, było katastrofą. Każdy endpoint zwracał inną strukturę odpowiedzi. Niektóre rzucały błędy jako 200 z polem error. Niektóre używały HTTP status codes losowo. Frontendowcy w zespole klienta przepłakali to przez 6 miesięcy.

Dziś API, które buduję dla klientów w agencji, wyglądają konsystentnie, są łatwe do dokumentowania i frontendowcy nie krzyczą.

Ten post to 7 zasad, które wyciągnąłem z 6 lat budowania API. Bez akademickich definicji REST. Konkretne praktyki, które ratują projekt przed refaktorem.

Zasada 1: konsystentna struktura odpowiedzi

Najczęstszy grzech młodego API. Każdy endpoint zwraca inną strukturę.

Złe:

// GET /users/123
{ "id": 123, "name": "Jan" }

// GET /products
{ "data": [...], "total": 50 }

// GET /orders/456
{ "order": { "id": 456 } }

Trzy różne struktury, każdy z innym kluczem dla danych. Frontend musi pamiętać “dla users to plain object, dla products to data, dla orders to order”. Recipe for bugs.

Dobre:

// GET /users/123
{ "data": { "id": 123, "name": "Jan" }, "meta": null }

// GET /products
{ "data": [...], "meta": { "total": 50, "page": 1 } }

// GET /orders/456
{ "data": { "id": 456 }, "meta": null }

Jeden wzorzec. Frontend zawsze sięga po response.data. meta zawiera dodatkowe info (paginacja, totals, czas), gdy potrzebne.

Można też używać konwencji JSON:API, ale dla większości projektów własna prosta konwencja wystarczy. Ważne, żeby była JEDNA.

Zasada 2: HTTP status codes używaj poprawnie

Drugi najczęstszy grzech. Wszystkie odpowiedzi jako 200, nawet błędy.

Złe:

GET /users/999 (nie istnieje)
HTTP 200
{ "error": "User not found" }

Dobre:

GET /users/999 (nie istnieje)
HTTP 404
{ "error": { "code": "USER_NOT_FOUND", "message": "User not found" } }

Status codes, których naprawdę potrzebujesz w 90% API:

  • 200 OK: udane GET, POST, PUT, PATCH
  • 201 Created: udane POST z stworzeniem zasobu (zwracaj Location header)
  • 204 No Content: udane DELETE (lub PUT bez zwracania danych)
  • 400 Bad Request: błąd walidacji (np. brakujące pole)
  • 401 Unauthorized: brak autentykacji (lub niepoprawna)
  • 403 Forbidden: jest auth, ale nie ma uprawnień
  • 404 Not Found: zasób nie istnieje
  • 409 Conflict: konflikt z istniejącym stanem (np. duplicate email)
  • 422 Unprocessable Entity: walidacja semantyczna failed
  • 429 Too Many Requests: rate limit hit
  • 500 Internal Server Error: błąd serwera
  • 503 Service Unavailable: serwis przeciążony / w utrzymaniu

Inne kody używaj tylko, jeśli wiesz dokładnie po co. Lepiej 5 znanych niż 50 mylonych.

Zasada 3: paginate WSZYSTKO, co może urosnąć

Endpoint, który zwraca listę, zawsze pagunj. Nawet jeśli dziś jest 10 rekordów, jutro może być 10 000.

Złe:

GET /products
[ { ... }, { ... }, { ... } ]  // 50 000 produktów, MB danych, 5 sek

Dobre:

GET /products?page=1&pageSize=20
{
  "data": [...],
  "meta": {
    "page": 1,
    "pageSize": 20,
    "total": 50000,
    "totalPages": 2500
  }
}

Dwa style paginacji:

Offset-based (page + pageSize): łatwiej, większości API wystarczy. Problem przy bardzo dużych offset’ach (DB wolne).

Cursor-based (cursor + limit): wydajniejsze dla skali, stabilne przy zmieniających się danych. Trudniejsze w implementacji.

Dla 95% API offset wystarczy. Cursor dorzucasz, kiedy faktycznie boli (Twitter feed, infinite scroll na bardzo aktywnych danych).

Zasada 4: filtruj i sortuj przez query params, nie różne endpointy

Złe:

GET /products/active
GET /products/inactive
GET /products/by-category/123
GET /products/sorted-by-price-asc

Każda zmiana = nowy endpoint. Niemożliwe do utrzymania.

Dobre:

GET /products?status=active&category=123&sort=price:asc

Jeden endpoint, kombinacje przez parametry. Frontend ma elastyczność, backend ma jeden punkt logiki.

Konwencja sortowania, którą polecam:

?sort=price:asc,createdAt:desc

Filter: po polach equality (?status=active). Dla zakresów: ?priceMin=10&priceMax=100.

Skomplikowane filtry (AND/OR/NOT) zwykle znaczą, że potrzebujesz dedykowanego endpointu (np. /products/search) z body JSON.

Zasada 5: wersjonowanie od pierwszej linijki kodu

Niedoceniana zasada, dopóki nie boli.

W pierwszej wersji API nie myślisz o breaking changes. W drugiej okazuje się, że trzeba zmienić strukturę, ale 30 klientów już używa starej. Migrate’ujesz wszystkich na siłę = łamiesz produkcję. Nie zmieniasz nic = utykasz w technicznym długu.

Wersjonuj od dnia 1. Dwie konwencje:

URL versioning (najczęstsze):

GET /v1/products
GET /v2/products  (po zmianach)

Header versioning (czystsze, mniej popularne):

GET /products
Accept: application/vnd.api+json; version=1

Wybierz jedno, trzymaj się. Dla 90% projektów URL versioning jest bezpieczniejsze (łatwiej dla debug, dla cache, dla frontu).

Zasada 6: error handling musi być przewidywalny

Frontend musi wiedzieć, jak obsłużyć błędy. Konsystentny format pomaga.

Złe:

{ "error": "Email already taken" }
{ "errors": [{ "field": "email", "message": "taken" }] }
{ "msg": "Validation failed", "details": "Email exists" }

Trzy różne struktury w jednym API. Frontend musi pisać 3 różne handlery.

Dobre:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "code": "ALREADY_TAKEN",
        "message": "Email is already in use"
      }
    ]
  }
}

Każdy błąd ma:

  • code: machine-readable (frontend może switch’ować po nim)
  • message: human-readable (do logów albo UI)
  • details (opcjonalne): array błędów per pole

Frontend pisze jeden handler, działa wszędzie.

Zasada 7: dokumentacja jako kod, nie jak ostatnia myśl

Najczęstszy fail: API zbudowane, dokumentacja w Wordzie z 6 miesięcy temu, nieaktualna, nikt jej nie czyta.

Standardy:

OpenAPI (Swagger): dokumentacja w YAML/JSON, generuje interaktywną dokumentację. Tools: Swagger UI, Redoc, Stoplight.

Generatory z kodu: TSOA dla TypeScript, FastAPI dla Pythona, Spring dla Javy. Dekoratorach w kodzie generujesz docs automatycznie.

Tests as docs: napisz przykładowe requesty/responses w testach. Postman collections. cURL examples w README.

Moja stack: OpenAPI generowany z kodu TypeScript (TSOA), wynik wrzucany na Stoplight albo własną stronę z Redoc. Update’uje się automatycznie z code change’em.

Bonus: SDK generation. Z OpenAPI generujesz client SDK w 20 językach. Frontend dostaje typesafe SDK, nie pisze fetch’ów ręcznie.

REST vs GraphQL vs RPC

Krótko, bo to temat na osobny post.

REST: standard w 80% projektów. Łatwy, dobrze ugruntowany, ogromne tooling.

GraphQL: świetny, gdy klient potrzebuje elastyczności w doborze pól (np. dashboard z różnymi widokami). Overhead w prostych CRUDach. Nadmierne dla większości projektów.

tRPC: jeśli stack JS/TS end-to-end. Typesafe API, zero codegen. Świetny dla monorepo Next.js + backend Node.

gRPC: backend-to-backend, internal services. Performance-critical. Niespecjalnie do API publicznych.

Dla agencji robiącej projekty klientów: REST default, tRPC dla monorepo, GraphQL na życzenie klienta jeśli ma sens biznesowy.

Autentykacja: JWT vs sesje

JWT (JSON Web Tokens): stateless, signed, klient trzyma w localStorage albo cookie. Skalowalne, ale revocation trudne. Standard dla SPA, mobile.

Sesje (cookie + server-side store): stateful, server pamięta o sesji. Łatwiejsze revocation. Klasyczne dla SSR app.

Dla nowoczesnych projektów Next.js + Strapi: JWT z refresh tokens. Lepiej skaluje, łatwiej w mobile, lepiej dla microservices.

Refresh token strategy:

  • Access token: krótki (15 min), w memory
  • Refresh token: dłuższy (7 dni), w HttpOnly cookie
  • Refresh endpoint: dostaje refresh token, zwraca nowy access token
  • Logout: revoke refresh token w bazie

Bez refresh tokens user musi się logować co 15 minut. To bezsensowne.

Rate limiting: kiedy i jak

Każde publiczne API potrzebuje rate limiting. Inaczej jeden klient zatkanym serwer.

Strategie:

  • Token bucket: klient ma N tokenów, każdy request zjada 1. Tokeny regenerują się X/sek.
  • Sliding window: ile requestów w ostatnich N minutach.
  • Fixed window: ile requestów w danym kwadransie/godzinie.

Library, której używam: express-rate-limit (Node), slowapi (Python), wbudowane w Strapi v5.

Headers, które warto zwracać:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716105600

Frontend dostosowuje retry behavior.

Webhooks: kiedy push, kiedy poll

Push (webhook): klient daje URL, API woła go przy zmianie. Niska latencja, mniej obciążenia.

Poll: klient sam pyta co X sekund. Prostsze, ale wolniejsze i obciąża API.

Wybierz webhook, jeśli:

  • Latencja ma znaczenie
  • Klient jest serwerem, nie aplikacją w browser
  • Wydarzenia są rzadkie ale ważne (płatność, status zamówienia)

Wybierz polling, jeśli:

  • Klient jest browserem (webhooks z browsera trudne)
  • Wydarzenia są częste i mało krytyczne (dashboard metrics)
  • Klient ma firewall blokujący incoming

Mój standard: webhooks dla event-driven flows, polling dla dashboards.

Najczęstsze błędy

Brak idempotency w POST. Klient pingnie POST 2 razy z powodu network glitch, masz 2 zamówienia. Dodaj Idempotency-Key header.

Wystawianie wewnętrznych ID. Jeśli używasz auto-increment ID, ktoś może iterować po /users/1, /users/2. Użyj UUID albo coding internal ID.

Brak CORS configuration. Frontend z innej domeny dostaje 403. Skonfiguruj CORS od dnia 1.

Sekrety w odpowiedzi. API zwraca passwordHash, refreshToken, internalNotes. Filtruj odpowiedzi przez DTO/Serializer.

Brak request ID. Klient zgłasza “request 5 minut temu padł”. Bez X-Request-ID szukasz po linii w logach. Z request ID = grep do jednego konkretnego flow.

Logowanie bez sanityzacji. Logi pełne tokenów, haseł, danych osobowych. Sanityzuj przed log.

Co czytać dalej

  • “REST API Design Rulebook” Mark Massé. Klasyk, krótki, esencjonalny.
  • “API Design Patterns” JJ Geewax. Współczesny, praktyczny, oparty na Google API Design.
  • stripe.com/docs. Najlepsze API w branży. Jak masz wątpliwość, sprawdź, jak Stripe to zrobił.
  • OpenAPI Specification. Standard, którego warto znać.

Od czego zacząć

Jeśli projektujesz pierwsze API:

  1. Wybierz konwencję response struct. Wpisz w CONTRIBUTING.md. Trzymaj się.
  2. Sprawdź każdy status code, którego używasz. Czy poprawnie?
  3. Paginate wszystko, co może urosnąć.
  4. Wersjonuj URL od dnia 1 (/v1/).
  5. Konsystentny error format.
  6. OpenAPI generowane z kodu, hostowane gdzieś dostępnym.
  7. Rate limiting od dnia 1, nawet jeśli low limit.

Dobre API projektuje się raz. Złe API refaktoruje się przez 2 lata. Inwestuj na początku, oszczędzaj potem.

Pisałem o stack agencji w postach o TypeScript, Next.js, TurboRepo. Dobry API design to fundament reszty.