Przejdź do treści

Backend dla frontendowca: serwer, bazy danych i API

Pierwsza część serii Backend dla frontendowca: architektura aplikacji, serwer, bazy danych, API, statusy HTTP, paginacja, idempotencja, BFF i CORS.

Maciej Sala

Founder StriveLab

21 min czytaniaOpublikowano 28 lipca 2025 (Aktualizacja 30 lipca 2026)

To pierwsza część serii Backend dla frontendowca, w której zaczynamy od fundamentów, czyli architektury aplikacji webowej, serwera aplikacji, baz danych, kontraktu i . To są tematy, które frontendowiec najczęściej dotyka jako pierwszy, nawet jeśli formalnie nie pisze backendu na co dzień.

Nie musisz od razu stawać się backendowcem, ale warto jednak znać język, którym posługuje się druga strona zespołu. Dzięki temu szybciej zrozumiesz, dlaczego formularz zwraca 422, czemu lista ładuje się pięć sekund, skąd biorą się problemy z CORS-em i dlaczego tylko jeden endpoint API czasem oznacza kilka dni pracy.

Backend dla frontendowca w architekturze aplikacji webowej

Zanim wejdziemy w szczegóły, warto zobaczyć cały obraz. Dla użytkownika aplikacja jest jednym ekranem w przeglądarce, ale już dla systemu to zestaw współpracujących warstw: frontend wysyła żądanie, load balancer kieruje je do jednego z serwerów API, serwer rozmawia z bazą, pamięcią podręczną i magazynem plików, a potem wszystko wraca do jako odpowiedź.

Diagram
Podstawowa architektura aplikacji webowej

To oczywiście duże uproszczenie, ale bardzo przydatne w praktyce, ponieważ większość problemów odbieranych przez frontend jako wolno działający ekran lub dziwny błąd ma swoje źródło właśnie na styku tych wymienionych wyżej warstw.

1. Serwer aplikacji jako serce backendu

W serwerze aplikacji sprawdzasz, czy użytkownik może wykonać akcję, walidujesz dane, zapisujesz coś w bazie i decydujesz, jaką odpowiedź powinien dostać frontend.

  • nasłuchuje na porcie, na przykład 3000,
  • przyjmuje i ogranicza rozmiar żądań,
  • uwierzytelnia użytkownika i sprawdza uprawnienia,
  • waliduje dane oraz wykonuje logikę biznesową,
  • zarządza limitami czasu i anulowaniem pracy,
  • zwraca odpowiedź w stabilnym formacie.

Popularne opcje serwera aplikacji

Połączenie Node.js oraz Express stanowi najczęstszy punkt wejścia dla osób wywodzących się z frontendu, ponieważ pozwala pozostać przy znajomym języku JavaScript, jednocześnie przenosząc istotną część logiki aplikacji na stronę serwera:

Code
import express from 'express'
 
const app = express()
 
app.get('/api/users', async (req, res) => {
  const { rows } = await db.query(
    'SELECT id, name, email FROM users ORDER BY id LIMIT $1',
    [50],
  )
  res.json({ data: rows })
})
 
app.listen(3000)

Inne popularne opcje w JS i TS:

  • Fastify oferuje walidację opartą na JSON Schema i architekturę pluginów.

  • Hono działa w wielu środowiskach uruchomieniowych, między innymi w środowiskach edge, Node.js, Deno i Bun.

  • NestJS narzuca modułową strukturę aplikacji i dobrze pasuje do zespołów, które potrzebują wspólnych konwencji.

  • Bun jest środowiskiem uruchomieniowym, a nie frameworkiem. Oferuje własny zestaw narzędzi i część zgodności z API Node.js.

  • Deno jest środowiskiem uruchomieniowym z modelem uprawnień. Dostęp do sieci, systemu plików i zmiennych środowiskowych można jawnie ograniczać.

Pozostałe języki:

  • Python: Django, FastAPI, Flask,
  • Ruby: Ruby on Rails,
  • Go: Gin, Echo,
  • Java: Spring Boot,
  • PHP: Laravel,
  • .NET: ASP.NET Core.

Co robi serwer aplikacji w backendzie?

W najprostszej wersji serwer aplikacji jest tylko routerem i warstwą pośrednią między frontendem a bazą. W realnych projektach bardzo szybko dochodzą kolejne odpowiedzialności:

  1. Routing mapuje metodę i URL na kod.
  2. Middleware obsługuje przekrojowe reguły, między innymi uwierzytelnianie, logowanie i CORS.
  3. Warstwa aplikacyjna wykonuje przypadki użycia.
  4. Logika domenowa pilnuje reguł biznesowych.
  5. Warstwa danych wykonuje zapytania i transakcje.
  6. Warstwa HTTP formatuje odpowiedzi i statusy.

2. Bazy danych w backendzie aplikacji webowej

Baza danych jest tym elementem backendu, którego najłatwiej nie docenić na początku. Dopóki masz kilka rekordów, wszystko jest proste. Problemy zaczynają się, gdy dochodzą relacje, filtry, indeksy, migracje, transakcje i rosnąca liczba użytkowników.

Bazy SQL, czyli relacyjne bazy danych

Dane w tabelach z relacjami:

Code
-- Tabele
users (id, name, email)
posts (id, user_id, title, content)
 
-- Relacja przez user_id
SELECT users.name, posts.title
FROM users
JOIN posts ON users.id = posts.user_id

Kiedy : e-commerce, finanse, CRM, panele administracyjne i większość klasycznych aplikacji biznesowych. Jeżeli dane mają relacje i potrzebujesz spójności, SQL jest bezpiecznym domyślnym wyborem.

Popularne: PostgreSQL, MySQL, SQLite

Bazy NoSQL, czyli nierelacyjne bazy danych

Dokumenty przypominające JSON:

Code
{
  "_id": "abc123",
  "name": "Jan",
  "posts": [{ "title": "Post 1" }, { "title": "Post 2" }]
}

Kiedy wybrać NoSQL: w sytuacjach, gdy doskonale znasz dominujące wzorce odczytu oraz zapisu, a model wybranej bazy dokumentowej lub klucz-wartość idealnie do nich pasuje. Warto pamiętać, że obsługa mediów społecznościowych, działanie w czasie rzeczywistym czy obsługa dużej skali same w sobie nie stanowią automatycznych argumentów przemawiających za NoSQL, ponieważ PostgreSQL równie dobrze radzi sobie z obsługą danych w formacie JSON, pełnymi transakcjami oraz ogromnymi zbiorami danych. Ostateczna decyzja powinna wynikać z analizy relacji między danymi, rygorystycznych wymagań dotyczących spójności, specyfiki planowanych zapytań oraz wybranego sposobu skalowania systemu.

Popularne: MongoDB, Cloud Firestore, Firebase Realtime Database, DynamoDB

ORM jako abstrakcja nad bazą danych

ORM albo query builder pozwala pisać zapytania w kodzie aplikacji, zamiast składać SQL ręcznie w każdym miejscu. To wygodne, bo dostajesz typowanie, migracje i mniej powtarzalnego kodu szablonowego. Nadal warto jednak rozumieć SQL, bo ORM nie zwalnia z myślenia o indeksach, JOIN-ach i kosztach zapytań.

Code
// Prisma
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { posts: true },
})
 
// Zamiast:
// SELECT * FROM users
// LEFT JOIN posts ON users.id = posts.user_id
// WHERE users.id = 1

Popularne ORM-y i query buildery: Prisma, Drizzle, Kysely, TypeORM (Node), SQLAlchemy (Python), Hibernate (Java), Eloquent (Laravel), Active Record (Rails).

Indeksy i szybkie wyszukiwanie w bazie

Indeks działa trochę jak spis treści w książce. Bez niego baza może przejrzeć wiele wierszy, żeby znaleźć właściwy rekord. Przy selektywnym warunku i odpowiednim indeksie planer zapytań może przejść do znacznie mniejszego zakresu danych. Dla unikalnego adresu e-mail warto utworzyć indeks, który jednocześnie egzekwuje regułę biznesową.

Code
CREATE UNIQUE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);

Kilka zasad wystarczy na start:

  • Indeks projektuj pod konkretne zapytanie, jego selektywność, WHERE, JOIN i ORDER BY.

  • Kolejność kolumn w indeksie złożonym ma znaczenie. B-tree na (user_id, created_at) najskuteczniej ogranicza skan, gdy zapytanie filtruje po wiodącym user_id.

  • Indeks unikalny jednocześnie przyspiesza wyszukiwanie i egzekwuje brak duplikatów. Nie twórz obok niego drugiego indeksu na ten sam zestaw kolumn bez wyraźnego powodu.

  • Każdy indeks zajmuje miejsce i zwiększa koszt zapisu. Nie indeksuj na zapas.

  • Zacznij od EXPLAIN, a EXPLAIN ANALYZE uruchamiaj świadomie, ponieważ faktycznie wykonuje zapytanie.

Ograniczenia bazy jako ostatnia linia obrony

Walidacja w Zod lub formularzu poprawia komunikaty, ale nie chroni przed dwoma żądaniami wykonywanymi równocześnie. Reguły integralności, które zawsze muszą obowiązywać, zapisuj również w schemacie bazy.

Code
CREATE TABLE posts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  slug TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL CHECK (status IN ('draft', 'published')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

PRIMARY KEY, UNIQUE, FOREIGN KEY, NOT NULL i CHECK chronią dane niezależnie od tego, czy zapis pochodzi z API, zadania w tle, migracji czy konsoli administracyjnej. Backend powinien przechwycić błąd ograniczenia i zamienić go na stabilny błąd domenowy, na przykład 409 Conflict.

Transakcje w bazie danych: wszystko albo nic

Transakcja jest potrzebna wtedy, gdy kilka operacji musi wydarzyć się jako jedna całość. Najprostszy przykład to przelew: -100 z konta A i +100 na konto B. Jeżeli druga operacja się nie powiedzie, wtedy pierwsza nie może zostać w bazie.

Code
await prisma.$transaction(async (tx) => {
  const debit = await tx.account.updateMany({
    where: { id: 1, balanceInCents: { gte: 10_000 } },
    data: { balanceInCents: { decrement: 10_000 } },
  })
 
  if (debit.count !== 1) {
    throw new Error('Insufficient funds')
  }
 
  await tx.account.update({
    where: { id: 2 },
    data: { balanceInCents: { increment: 10_000 } },
  })
})

Jeśli druga operacja rzuci błąd przed zatwierdzeniem transakcji, pierwsza zostanie cofnięta przez ROLLBACK. Przekroczenie limitu czasu żądania w przeglądarce nie gwarantuje jednak anulowania pracy w bazie. Serwer musi poprawnie propagować anulowanie, ustawić limity czasu transakcji i ustalić wynik operacji przed bezpiecznym ponowieniem.

ACID opisuje fundamentalne właściwości transakcji w relacyjnej bazie. Są to atomowość, spójność, izolacja i trwałość.

Sama transakcja nie rozwiązuje każdego wyścigu. Dwa równoległe przelewy mogą odczytać ten sam stan konta. W zależności od reguły potrzebujesz warunkowego UPDATE, blokady wiersza przez SELECT ... FOR UPDATE, ograniczenia CHECK albo wyższego poziomu izolacji z obsługą ponowienia transakcji.

Problem N+1, który szybko odczuwa frontend

Problem N+1 jest świetnym przykładem błędu backendowego, który frontend odczuwa bardzo wyraźnie. Kod pozornie wygląda niewinnie: pobierasz listę, a potem dla każdego elementu dociągasz autora. Przy małej liczbie rekordów jakoś działa, ale przy większej liczbie zaczyna zabijać czas odpowiedzi.

Code
// N+1 oznacza 1 zapytanie oraz N zapytań po autorów
const posts = await prisma.post.findMany()
for (const post of posts) {
  post.author = await prisma.user.findUnique({
    where: { id: post.authorId },
  })
}
 
// Pobieranie relacji z wyprzedzeniem lub batching ogranicza liczbę zapytań
const postsWithAuthors = await prisma.post.findMany({
  include: { author: true },
})

Dla 100 postów pierwsza wersja wykonuje 101 zapytań. Wersja z relacją ogranicza tę liczbę, ale nie musi generować dokładnie jednego JOIN. ORM może użyć jednego zapytania albo kilku zapytań grupowych zależnie od konfiguracji i strategii ładowania relacji. Sprawdzaj rzeczywisty SQL i liczbę zapytań, zamiast zakładać zachowanie na podstawie samego kodu ORM.

Pula połączeń z bazą danych

Otwarcie nowego połączenia do bazy wymaga połączenia sieciowego, negocjacji protokołu i uwierzytelnienia. Pula utrzymuje ograniczoną liczbę gotowych połączeń współdzielonych między żądaniami.

Code
// konfiguracja puli pg
import { Pool } from 'pg'
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // max połączeń
  idleTimeoutMillis: 30000, // zamknij bezczynne po 30s
})

W środowiskach : platforma może uruchomić wiele instancji równolegle, a każda z nich otwiera własną pulę połączeń, przez co ich suma łatwo przekracza limit bazy danych. Aby tego uniknąć, warto skorzystać z poolera dostawcy lub PgBouncera, odpowiednio ograniczyć rozmiar puli dla pojedynczej instancji oraz na bieżąco monitorować liczbę aktywnych i oczekujących połączeń. W środowiskach krótkotrwałych dobrze sprawdza się również sterownik zaprojektowany specjalnie z myślą o architekturze serverless.

Migracje i wersjonowanie schematu bazy danych

Schemat bazy też jest częścią aplikacji. Dodanie kolumny, zmiana typu albo usunięcie tabeli nie powinny być ręczną akcją „klikniętą na produkcji”. Migracje zapisują te zmiany jako kod, który trafia do repozytorium i może zostać wykonany w tej samej kolejności na każdym środowisku.

Code
prisma migrate dev --name add_user_role
Code
-- 20260101_add_user_role/migration.sql
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'user';

Każde środowisko wykonuje te same migracje w tej samej kolejności. prisma migrate dev służy do pracy lokalnej. Na produkcji użyj procesu wdrożeniowego, na przykład prisma migrate deploy, i nie generuj migracji podczas startu każdej instancji.

Przy wdrożeniach bez przestoju stosuj schemat expand and contract. Najpierw dodaj kompatybilne pole lub tabelę, potem wdroż kod obsługujący stary i nowy schemat, uzupełnij historyczne dane, przełącz odczyty, a dopiero w osobnym wdrożeniu usuń stary element. Duże migracje testuj na danych zbliżonych rozmiarem do produkcji i obserwuj blokady.

3. API jako umowa między frontendem a backendem

API nie jest tylko technicznym endpointem, ponieważ to kontrakt między zespołami i warstwami aplikacji. Jeśli kontrakt jest niejasny, frontend zaczyna zgadywać: czy pusta tablica oznacza brak danych, czy błąd? Czy 400 to walidacja, czy zepsuty JSON? Czy żądanie POST można ponowić? Dobre API usuwa takie pytania.

REST API

REST to najczęściej spotykane podejście, bo dobrze mapuje się na HTTP: zasoby mają adresy, a akcje są opisane metodami.

Code
GET    /api/users        → lista użytkowników
GET    /api/users/123    → użytkownik o id 123
POST   /api/users        → utwórz użytkownika
PUT    /api/users/123    → zastąp pełną reprezentację użytkownika
PATCH  /api/users/123    → częściowo zaktualizuj użytkownika
DELETE /api/users/123    → usuń użytkownika

PUT opisuje utworzenie lub pełne zastąpienie stanu zasobu pod znanym URI i jest idempotentny. Częściową zmianę kilku pól zwykle lepiej wyrazić przez PATCH. Nazwa endpointu API nie wystarczy. Backend i dokumentacja muszą zachować semantykę metody.

GraphQL

GraphQL przesuwa sporą część kontroli bezpośrednio w ręce klienta, dzięki czemu interfejs nie otrzymuje odgórnie narzuconej, sztywnej struktury odpowiedzi, lecz może precyzyjnie zadeklarować dokładnie takie pola, jakie są mu w danym momencie potrzebne:

Code
query {
  user(id: 123) {
    name
    email
    posts {
      title
    }
  }
}

To jest bardzo wygodne przy produktach, w których różne widoki potrzebują różnych kształtów danych. Dzieje się to za cenę większej złożoności po stronie serwera, pamięci podręcznej i obserwowalności. Produkcyjne GraphQL wymaga limitu głębokości lub kosztu zapytania, limitów batchingu i paginacji list. Resolver pól może też stworzyć N+1, dlatego często stosuje się batching w stylu DataLoader.

tRPC

tRPC jest najbardziej atrakcyjne wtedy, gdy cały stos technologiczny jest w TypeScripcie. Zamiast ręcznie synchronizować typy żądań i odpowiedzi, frontend korzysta z typów wyprowadzonych bezpośrednio z backendu.

Code
// Backend
export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input.id } })
    }),
})
 
// Frontend z pełnym typowaniem
const user = await trpc.getUser.query({ id: 123 })

Statusy HTTP, które powinien rozumieć frontend

Status HTTP stanowi pierwszą informację, jaką interfejs otrzymuje z API, więc traktowanie każdego kodu różnego od 200 w ten sam sposób oznacza utratę cennego kontekstu. Kody 401, 403, 422 oraz 429 wymagają zupełnie innych reakcji w warstwie wizualnej aplikacji.

KodZnaczenieTypowa reakcja klienta
200 OKSukces z reprezentacjąOdczytaj body
201 CreatedUtworzono zasóbOdczytaj body i Location, jeśli jest dostępny
202 AcceptedPrzyjęto pracę asynchronicznąPokaż oczekiwanie i odpytuj zasób operacji
204 No ContentSukces bez bodyNie próbuj parsować JSON
304 Not ModifiedReprezentacja w pamięci podręcznej jest aktualnaUżyj zapisanej odpowiedzi
400 Bad RequestBłędna składnia lub ogólne żądaniePopraw żądanie
401 UnauthorizedBrak prawidłowego uwierzytelnieniaOdśwież sesję albo pokaż logowanie
403 ForbiddenBrak uprawnieńPokaż brak dostępu
404 Not FoundZasób nie istnieje lub jest ukrytyPokaż stan braku danych
409 ConflictKonflikt z aktualnym stanemOdśwież dane lub rozwiąż konflikt
410 GoneZasób został trwale usuniętyUsuń lokalne odwołanie
412 Precondition FailedETag lub inny warunek nie pasujePobierz aktualną wersję i rozwiąż konflikt
415 Unsupported Media TypeNieobsługiwany Content-TypeWyślij dane w obsługiwanym formacie
422 Unprocessable ContentPoprawna składnia, błędna treśćPokaż błędy walidacji
429 Too Many RequestsPrzekroczono limitRespektuj Retry-After, jeśli serwer go zwraca
500 Internal Server ErrorNieoczekiwany błąd serweraPokaż bezpieczny komunikat i identyfikator żądania
502 / 503 / 504Problem bramy lub dostępnościPonawiaj tylko bezpieczne albo idempotentne operacje

Odpowiedź 401 powinna zawierać odpowiedni nagłówek WWW-Authenticate, natomiast odpowiedzi 204 oraz 304 nie posiadają ciała, przez co bezwarunkowe wywołanie metody response.json() po stronie klienta zakończy się nieobsłużonym błędem.

Struktura odpowiedzi błędów w API

Wysokiej jakości API nigdy nie ogranicza się do prostego komunikatu tekstowego, ponieważ interfejs potrzebuje precyzyjnych wytycznych, czy wyświetlić błąd pod konkretnym polem, wymusić ponowne logowanie, odczekać chwilę czy zaoferować ponowienie akcji, a do tego celu służą w pełni strukturalne błędy, których obecnym standardem branżowym jest specyfikacja RFC 9457 Problem Details:

Code
{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "Request body contains invalid fields",
  "instance": "/problems/req_01JABC123",
  "code": "VALIDATION_FAILED",
  "errors": [
    { "pointer": "/email", "detail": "Niepoprawny format" },
    { "pointer": "/password", "detail": "Minimum 12 znaków" }
  ]
}

Pola type, title, status, detail oraz instance posiadają znaczenie ściśle określone przez specyfikację RFC, podczas gdy errors i code stanowią autorskie rozszerzenia kontraktu. Frontend powinien podejmować decyzje na podstawie statusu, pola type lub stabilnego kodu błędu zamiast parsować tekst dla człowieka z pola detail. Adres umieszczony w polu type powinien prowadzić do dokumentacji danego problemu, o ile jest zwykłym adresem URL, natomiast instance jednoznacznie identyfikuje konkretne wystąpienie błędu i pomaga w jego korelacji z logami systemowymi.

W środowisku produkcyjnym bezwzględnie nie wolno ujawniać stosu wywołań, zapytań SQL ani poufnych sekretów, ponieważ standard Problem Details służy wyłącznie do czytelnego opisu błędów w interfejsie HTTP, a nie do prezentowania wewnętrznej diagnostyki serwera końcowemu użytkownikowi.

Walidacja danych wejściowych w backendzie

Frontend może walidować formularz dla wygody użytkownika, ale backend musi walidować dane ponownie. Użytkownik może ominąć UI, wysłać żądanie przez curl, zmodyfikować treść żądania w DevTools albo użyć starej wersji aplikacji i dlatego właśnie walidacja musi stać na granicy API.

Typowy przepływ:

  1. Parsujesz JSON i sprawdzasz Content-Type.
  2. Ograniczasz rozmiar ciała żądania, długość pól i złożoność danych.
  3. Walidujesz dozwolony kształt danych schematem i odrzucasz nieznane pola.
  4. Normalizujesz dane tylko według jawnych reguł.
  5. Walidujesz reguły biznesowe i aktualny stan systemu.
  6. Polegasz na ograniczeniach bazy przy wyścigach.
  7. Zwracasz 422 dla błędnej treści albo 409 dla konfliktu stanu.

Przykład z Zod:

Code
import { z } from 'zod'
 
const createUserSchema = z
  .object({
    email: z.string().email(),
    password: z.string().min(12).max(128),
    name: z.string().min(2).max(80),
  })
  .strict()
 
app.post('/api/users', async (req, res) => {
  const result = createUserSchema.safeParse(req.body)
 
  if (!result.success) {
    return res.status(422).json({
      type: 'https://api.example.com/errors/validation',
      title: 'Validation failed',
      status: 422,
      errors: result.error.issues.map((issue) => ({
        pointer: `/${issue.path.join('/')}`,
        detail: issue.message,
      })),
    })
  }
 
  const user = await createUser(result.data)
  return res
    .status(201)
    .location(`/api/users/${user.id}`)
    .json({ id: user.id, email: user.email, name: user.name })
})

Dla frontendowca najważniejsze jest to, żeby backend zwracał błędy w stabilnym formacie. Wtedy UI nie musi parsować losowych tekstów i może powiązać pointer z konkretnym polem formularza.

Waliduj także dane wychodzące. Nie zwracaj bezpośrednio całego obiektu z ORM, ponieważ może zawierać hash hasła, flagi administracyjne lub pola wewnętrzne. Jawny DTO albo schema odpowiedzi ogranicza ryzyko przypadkowego wycieku przy późniejszej zmianie modelu.

Paginacja w API dla list i tabel

Brak limitu na liście to jeden z tych błędów, które potrafią długo pozostawać w ukryciu, ponieważ przy trzydziestu rekordach wszystko działa bez problemu. Przy trzydziestu tysiącach endpoint API zaczyna drastycznie zużywać pamięć, obciążać bazę danych i generować ogromny transfer. Właśnie dlatego wszystkie publiczne listy powinny mieć narzucony maksymalny limit, a większe zbiory danych dodatkowo wspierać paginację.

W praktyce najczęściej spotyka się dwa podstawowe wzorce, z których offset i limit są najprostsze i doskonale wspierają numerowane strony, choć przy dużych wartościach offsetu stają się kosztowne ze względu na to, że baza danych wciąż musi wykonać pracę nad obliczeniem i pominięciem wcześniejszych wierszy.

Code
GET /api/posts?page=2&limit=20
Code
{
  "data": [{ "id": 21, "title": "Przykładowy post" }],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 1547,
    "totalPages": 78
  }
}

Paginacja kursorowa dobrze pasuje do feedów i nieskończonego przewijania. Pozwala kontynuować od ostatniego elementu bez kosztu dużego offsetu.

Code
GET /api/posts?cursor=eyJpZCI6MTIzfQ&limit=20
Code
{
  "data": [{ "id": 103, "title": "Przykładowy post" }],
  "nextCursor": "eyJpZCI6MTAzfQ",
  "hasMore": true
}

Każda paginacja wymaga jawnego ORDER BY, który daje jednoznaczną kolejność. Samo created_at nie wystarczy, gdy kilka rekordów ma ten sam czas. Użyj pary (created_at, id) zarówno w sortowaniu, jak i warunku kolejnej strony.

Code
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Backend może zwrócić 20 elementów i zbudować nextCursor z dodatkowego, dwudziestego pierwszego rekordu. Kursor powinien być nieprzezroczysty dla klienta. Base64 jest tylko kodowaniem, nie zabezpieczeniem. Waliduj pola kursora, a jeśli klient nie może ich modyfikować, podpisz token.

Offset sprawdza się w panelach z numeracją stron i potrzebą przejścia do strony 12. Kursor jest zwykle lepszy dla zmieniającego się feedu. Żaden wariant nie gwarantuje pełnej migawki danych bez dodatkowej strategii spójności. Liczenie dokładnego total także może być kosztowne przy złożonych filtrach.

Filtrowanie i sortowanie

Filtrowanie i sortowanie powinny mieć przewidywalną konwencję. Dzięki temu frontend nie musi uczyć się osobnego stylu dla każdego endpointu API.

Code
GET /api/posts?status=published&author=42&sort=-created_at&fields=id,title
  • status=published filtruje status,
  • sort=-created_at ustawia kolejność malejącą,
  • fields=id,title ogranicza zwracane pola.

Backend musi stosować listę dozwolonych filtrów, pól i kierunków sortowania. Nie wstawiaj nazw kolumn ani fragmentów ORDER BY bezpośrednio z parametrów query string do SQL.

Idempotencja i bezpieczne ponawianie żądań POST

Frontend czasem musi ponowić żądanie, gdy połączenie zostanie zerwane albo pojawi się przekroczenie limitu czasu. Brak odpowiedzi nie mówi, czy serwer wykonał operację. Przy POST możesz więc przypadkiem stworzyć drugie zamówienie albo podwójną płatność. Klucz idempotencji pozwala backendowi rozpoznać tę samą intencję użytkownika.

Code
const idempotencyKey = crypto.randomUUID()
 
fetch('https://api.example.com/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({ amount: 100 }),
})

Klucz utwórz raz dla konkretnej intencji, na przykład kliknięcia „Zapłać”, zapisz go razem ze stanem operacji i wykorzystuj ponownie przy ponowieniu. Wygenerowanie nowego UUID przy każdej próbie omija ochronę.

Backend powinien:

  1. Ograniczyć zakres klucza do użytkownika lub tenanta, endpointu API i rodzaju operacji.
  2. Atomowo zarezerwować klucz przed wykonaniem efektu, używając unikalnego ograniczenia lub blokady.
  3. Zapisać hash istotnych parametrów. Ten sam klucz z inną treścią żądania musi zwrócić błąd.
  4. Rozróżniać status processing, zakończoną odpowiedź i błąd, aby równoległe żądania nie wykonały operacji dwa razy.
  5. Przechować status HTTP i ciało odpowiedzi przez jawnie udokumentowany czas.

Okres retencji zależy od API. Stripe API v1 pozwala usuwać klucze po co najmniej 24 godzinach, ale nie jest to uniwersalna wartość dla własnego systemu.

GET, PUT i DELETE są idempotentne według semantyki HTTP, jeśli backend implementuje je poprawnie. Idempotencja dotyczy zamierzonego efektu na serwerze, więc drugi DELETE może zwrócić inny status i nadal być idempotentny. POST nie jest idempotentny z definicji, a PATCH zależy od operacji. Ustawienie status=paid może być idempotentne, ale zwiększenie quantity o 1 już nie.

Kontrola współbieżności i ochrona przed utratą zmian

Idempotency key chroni przed powtórzeniem tej samej intencji, ale nie rozwiązuje konfliktu dwóch różnych edycji. Gdy dwie osoby otwierają ten sam rekord, późniejszy zapis może nadpisać wcześniejszą zmianę. Backend może zwrócić ETag opisujący wersję zasobu, a klient odesłać go w If-Match.

Code
GET /api/posts/123
ETag: "post-123-v7"
 
PATCH /api/posts/123
If-Match: "post-123-v7"

Jeśli aktualna wersja jest już inna, serwer zwraca 412 Precondition Failed zamiast nadpisywać dane. Podobny mechanizm można oprzeć na kolumnie version aktualizowanej warunkowo w bazie. Frontend powinien wtedy pobrać aktualne dane i pomóc użytkownikowi rozwiązać konflikt.

Wersjonowanie API

API żyje razem z produktem. Dzisiaj pole name wystarcza, jutro pojawia się displayName, za miesiąc dochodzi aplikacja mobilna, a za pół roku integracja partnerska. Wersjonowanie jest sposobem na zmianę kontraktu bez rozbijania istniejących klientów.

W URL jest proste i czytelne:

Code
/api/v1/users
/api/v2/users

W typie mediów utrzymuje stały URL:

Code
GET /api/users
Accept: application/vnd.example.v2+json

Bez jawnego numeru wersji wymaga zmian kompatybilnych wstecz:

  • nie usuwasz pola, dopóki korzystają z niego wspierani klienci,
  • nie zmieniasz znaczenia ani typu istniejącego pola,
  • nowe informacje dodajesz w opcjonalnych polach,
  • dokumentujesz okres wsparcia i sposób migracji.

Stara wersja działa równolegle przez okres deprecacji. RFC 9745 definiuje nagłówek Deprecation jako datę w formacie Structured Fields. RFC 8594 definiuje Sunset jako moment planowanego wyłączenia zasobu.

Code
Deprecation: @1785369600
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrations/v2>; rel="deprecation"

Sama obecność nagłówka nie zastępuje komunikacji z użytkownikami API, przewodnika migracji ani telemetrycznego sprawdzenia, którzy klienci nadal korzystają ze starej wersji.

OpenAPI i kontrakt API

Najgorszy rodzaj integracji to taki, w którym frontend zgaduje kształt odpowiedzi na podstawie jednego przykładu ze Slacka. Kontrakt API powinien być jawny: endpointy, parametry, statusy, ciało żądania, ciało odpowiedzi i przykłady błędów.

W REST najczęściej robi się to przez OpenAPI:

Code
openapi: 3.1.0
info:
  title: Users API
  version: 1.0.0
paths:
  /api/users:
    post:
      summary: Create user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, name]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  minLength: 12
                name:
                  type: string
      responses:
        '201':
          description: User created
        '422':
          description: Validation failed
          content:
            application/problem+json:
              schema:
                type: object

Co to daje frontendowi:

  • mniej zgadywania i domyślania się pól przy integracji z backendem,

  • możliwość automatycznego wygenerowania spójnych typów TypeScript bezpośrednio z kontraktu,

  • jednoznaczne statusy oraz przewidywalne formaty błędów ułatwiające obsługę w interfejsie,

  • prostsze i bardziej niezawodne mockowanie API w testach jednostkowych oraz integracyjnych,

  • szybsze i łatwiejsze wykrywanie wszelkich zmian łamiących wsteczną kompatybilność (breaking changes).

Jeśli pracujesz w TypeScripcie, dobrą alternatywą może być tRPC, w pełni typowane route handlers albo wspólne schematy Zod bądź Valibot, pamiętając jednak o tym, że same typy TypeScript nie walidują danych w trakcie działania programu. Aby dokumentacja nie rozjechała się z rzeczywistą implementacją, kontrakt powinien być regularnie sprawdzany za pomocą testów konsumenckich lub walidacji schematu w procesie CI.

BFF, czyli Backend for Frontend

BFF, czyli Backend for Frontend, to wzorzec, w którym frontend ma „swój” backend dopasowany do potrzeb konkretnego UI. Chodzi tutaj o sytuacje, w których ekran musi zebrać dane z kilku usług, ukryć sekrety albo przekształcić odpowiedź pod konkretny widok. W Next.js route handlers i server actions często pełnią właśnie tę rolę:

Code
// app/api/dashboard/route.ts agreguje 3 usługi w jednym wywołaniu
export async function GET() {
  try {
    const requestOptions = {
      headers: serverAuth,
      signal: AbortSignal.timeout(3000),
    }
    const [user, orders, notifications] = await Promise.all([
      fetch(`${USER_SERVICE}/me`, requestOptions),
      fetch(`${ORDER_SERVICE}/recent`, requestOptions),
      fetch(`${NOTIF_SERVICE}/unread`, requestOptions),
    ])
 
    if (![user, orders, notifications].every((response) => response.ok)) {
      throw new Error('Dependency unavailable')
    }
 
    return Response.json({
      user: await user.json(),
      orders: await orders.json(),
      notifications: await notifications.json(),
    })
  } catch {
    return Response.json(
      {
        type: 'https://api.example.com/errors/dependency',
        title: 'Dependency unavailable',
        status: 503,
      },
      { status: 503 },
    )
  }
}

Korzyści są następujące:

  1. Frontend nie musi znać topologii usług.
  2. Przeglądarka wykonuje mniej żądań.
  3. Klucze usług pozostają po stronie serwera.
  4. Kontrakt może być dopasowany do konkretnego interfejsu.

Minusem jest dodatkowa warstwa do utrzymania, ponieważ BFF musi mieć własne limity czasu, kontrolę współbieżności, obsługę częściowych awarii i budżet czasu odpowiedzi. Promise.all nie anuluje pozostałych żądań po błędzie jednego z nich, a wolna usługa nadal może opóźnić cały ekran.

Obserwowalność, logi i identyfikator żądania w backendzie

Frontendowiec często widzi tylko komunikat: „coś poszło nie tak”. Backend powinien dawać zespołowi sposób na znalezienie konkretnego żądania w logach.

Minimum produkcyjne:

  • Identyfikator żądania identyfikuje pojedyncze żądanie. Jeśli akceptujesz wartość od klienta, waliduj jej format i długość.

  • Kontekst śledzenia łączy wywołania między usługami. Standard W3C Trace Context używa między innymi nagłówka traceparent.

  • Strukturalne logi zawierają metodę, wzorzec trasy, status i czas odpowiedzi. Nie zapisuj pełnego URL-a z sekretami ani identyfikatora użytkownika bez potrzeby.

  • Metryki pokazują liczbę żądań, p95 i p99 opóźnienia oraz błędy.

  • Ślady pokazują czas spędzony w bazie i usługach zależnych.

  • Redakcja danych usuwa hasła, tokeny, pełne dane kart i wrażliwe dane osobowe.

OpenTelemetry dostarcza standardy i narzędzia do telemetryki, ale nie jest samym systemem przechowywania ani prezentacji danych. Dane trzeba wysłać do zgodnego kolektora i backendu obserwowalności.

Przykładowa odpowiedź może zawierać identyfikator żądania:

Code
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
X-Request-ID: req_01JABC123
Code
{
  "type": "https://api.example.com/errors/internal",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Unexpected error",
  "requestId": "req_01JABC123"
}

Dzięki temu użytkownik może wysłać wsparciu technicznemu identyfikator błędu, frontend może dołączyć go do raportu, a backend znajduje dokładny wpis w logach.

4. CORS i problemy integracji frontendu z backendem

CORS jest frustrujący, bo wygląda jak błąd backendu, a tak naprawdę jest protokołem egzekwowanym przez przeglądarkę. Skrypt nie może swobodnie odczytać odpowiedzi z innego originu, na przykład z https://api.example.com, gdy działa pod https://app.example.com. To rozszerzenie modelu Same-Origin Policy.

Jak działa CORS?

Dla tak zwanych simple requests przeglądarka wysyła żądanie bez wcześniejszego preflightu. Metoda musi być GET, HEAD albo POST, a nagłówki i Content-Type muszą mieścić się na liście bezpiecznej CORS. Authorization nie jest nagłówkiem safelisted, więc jego użycie uruchamia preflight.

Code
Access-Control-Allow-Origin: https://app.example.com

Jeśli nagłówka brakuje albo origin się nie zgadza, przeglądarka nie udostępni odpowiedzi JavaScriptowi. Samo żądanie mogło dotrzeć do serwera i wykonać efekt, dlatego CORS nie chroni przed CSRF.

Dla żądań niespełniających kryteriów simple request przeglądarka najpierw wysyła preflight OPTIONS.

Code
OPTIONS /api/users
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

Backend musi odpowiedzieć:

Code
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID, Location, ETag
Access-Control-Max-Age: 86400
Vary: Origin

Dopiero po poprawnym preflighcie przeglądarka wysyła właściwe żądanie. Access-Control-Max-Age pozwala przeglądarce przechować wynik preflightu, ale rzeczywisty czas jest ograniczany także przez implementację przeglądarki. Gdy serwer dynamicznie zwraca origin z listy dozwolonych adresów, powinien dodać Vary: Origin, aby pamięć podręczna nie pomieszała odpowiedzi dla różnych originów.

Konfiguracja CORS w Express

Code
import cors from 'cors'
 
app.use(
  cors({
    origin: ['https://app.example.com', 'http://localhost:3000'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    exposedHeaders: ['X-Request-ID', 'Location', 'ETag'],
    maxAge: 86400,
  }),
)

Typowe pułapki CORS

  • Wildcard nie działa z żądaniem uwierzytelnionym. Przy credentials musisz zwrócić konkretny dozwolony origin.

  • Fetch domyślnie wysyła credentials tylko do tego samego originu. Dla żądania cross-origin ustaw credentials: 'include', a backend musi zwrócić Access-Control-Allow-Credentials: true.

  • Polityka cookie nadal obowiązuje. SameSite, blokowanie third-party cookies i zakres Domain mogą zatrzymać cookie mimo poprawnego CORS.

  • Origin porównuj z dokładną listą dozwolonych adresów. Nie używaj luźnego sprawdzenia typu endsWith('example.com'), które może zaakceptować domenę atakującego.

  • Subdomena i port zmieniają origin. app.example.com, api.example.com, localhost:3000 i localhost:5173 to różne originy.

  • Nagłówki odpowiedzi mogą wymagać Access-Control-Expose-Headers. Bez niego JavaScript nie odczyta dowolnego własnego nagłówka, na przykład identyfikatora żądania.

Dlaczego CORS to nie autoryzacja?

CORS kontroluje, czy JavaScript w przeglądarce może odczytać odpowiedź z innego originu. Curl, aplikacja mobilna i żądanie wykonywane po stronie serwera mogą wywołać API bez oglądania się na CORS. Dlatego CORS nie zastępuje autoryzacji. Token, sesja i kontrola uprawnień są osobną warstwą.

Nie myl też CORS z ochroną przed CSRF. Jeśli używasz cookies i sesji, nadal potrzebujesz poprawnego SameSite, tokenów CSRF albo innej strategii ochrony operacji mutujących.

Lista kontrolna przed integracją endpointu API

Zanim frontend zacznie podpinać endpoint API, najlepiej ustalić kilka rzeczy:

  1. Jaki jest pełny URL, metoda HTTP i wymagane nagłówki?
  2. Czy endpoint API wymaga sesji, tokenu, ról albo konkretnych uprawnień?
  3. Jak wygląda ciało żądania i które pola są opcjonalne?
  4. Jakie statusy może zwrócić endpoint API: 200, 201, 204, 401, 403, 409, 422, 429, 5xx?
  5. Jaki jest stabilny format błędu i czy błędy formularza są mapowane per pole?
  6. Czy lista ma paginację, sortowanie, filtrowanie i limit maksymalny?
  7. Czy operację można ponowić i czy wymaga klucza idempotencji?
  8. Czy odpowiedź można buforować w pamięci podręcznej i jak długo?
  9. Czy endpoint API zwraca identyfikator żądania do debugowania?
  10. Czy istnieje OpenAPI, typ TypeScript, schemat Zod albo inny kontrakt?

Jeśli nie znasz odpowiedzi na te pytania, frontend i tak będzie musiał je odkryć metodą prób i błędów. Lepiej ustalić kontrakt, zanim UI zacznie zależeć od domysłów.

Problem w UI a możliwa przyczyna backendowa

Objaw w interfejsieMożliwa przyczyna backendowa
Lista długo pokazuje spinnerBrak indeksu, N+1, brak paginacji, wolne JOIN-y
Formularz pokazuje ogólny błądBrak strukturalnych błędów per pole
Użytkownik jest wyrzucany do logowania401, wygasła sesja, problem z cookie/tokenem
Zalogowany użytkownik widzi "brak dostępu"403, brak roli lub uprawnienia
Podwójne zamówienie po kliknięciuBrak idempotency key albo blokady po stronie UI
Żądanie działa w Postmanie, ale nie w UICORS, cookies, credentials, różny origin
Infinite scroll gubi lub duplikuje daneOffset pagination przy zmieniającym się zbiorze
Produkcja działa wolniej niż stagingBrak poolingu, zimne starty, inna skala danych
Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.
React

Pozostałe części serii

Często zadawane pytania

Od czego zacząć naukę backendu jako frontendowiec?

Dobry punkt startowy to Node.js z Expressem, Fastify albo Hono. Pozwala wykorzystać JavaScript, który już znasz, po stronie serwera. Naucz się przyjmować żądania HTTP, czytać parametry i body, zwracać JSON z odpowiednim statusem HTTP. Następnie dodaj PostgreSQL, migracje, ograniczenia bazy, paginację, walidację i strukturalne błędy. Zbuduj jedno działające API od żądania do zapisu danych, zanim przejdziesz do kolejnych wzorców.

Kiedy używać SQL a kiedy NoSQL?

SQL, na przykład PostgreSQL lub MySQL, wybierz, gdy dane mają relacje, potrzebujesz elastycznych zapytań i spójnych transakcji. To dobry wybór dla e-commerce, finansów, CRM i paneli administracyjnych. NoSQL, na przykład MongoDB, Cloud Firestore lub DynamoDB, rozważ, gdy model dostępu naturalnie pasuje do dokumentów lub kluczy i akceptujesz kompromisy konkretnej bazy dotyczące relacji, transakcji i spójności. Real-time ani duża skala same w sobie nie przesądzają o wyborze NoSQL. Jeśli nie masz wyraźnego powodu, PostgreSQL jest rozsądnym punktem startowym.

Co to jest ORM i czy warto go używać?

ORM albo query builder to warstwa między kodem a bazą danych. Piszesz np. prisma.user.findMany() zamiast ręcznego SQL w każdym miejscu. Prisma, Drizzle i Kysely są popularnymi wyborami w ekosystemie TypeScript. Zalety: typowanie, migracje, prostszy kod. Wady: złożone zapytania nadal wymagają rozumienia SQL, indeksów i planów wykonania.

Co to jest CORS i dlaczego ciągle psuje mi żądania?

CORS to mechanizm bezpieczeństwa w przeglądarce, który kontroluje, czy JavaScript może odczytać odpowiedź z innego originu. Dla żądań z JSON-em, niestandardowymi nagłówkami albo metodami PUT/PATCH/DELETE przeglądarka najpierw wysyła preflight OPTIONS. Curl, Postman i żądania wykonywane po stronie serwera nie podlegają CORS. Dlatego CORS nie zastępuje autoryzacji ani ochrony przed CSRF.

Czym jest idempotency key i kiedy go używać?

Klucz idempotencji to unikalny identyfikator wysyłany przede wszystkim przy operacjach POST, dzięki któremu backend rozpoznaje powtórzone żądanie. Przydaje się przy płatnościach, zamówieniach i operacjach, których nie wolno wykonać dwa razy po przekroczeniu limitu czasu, ponowieniu albo szybkim podwójnym kliknięciu. PATCH może być idempotentny lub nie. Zależy to od semantyki konkretnej operacji.

Jakie statusy HTTP zwracać przy walidacji formularza?

Najczęściej 422 Unprocessable Content, gdy typ i składnia body są poprawne, ale zawarte instrukcje nie przechodzą walidacji. 400 Bad Request pasuje do ogólnie błędnego żądania, 415 do nieobsługiwanego Content-Type, a 409 Conflict do konfliktu z aktualnym stanem, np. zajętego e-maila. W body zwróć Problem Details ze stabilnym kodem i rozszerzeniem opisującym konkretne pola.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Backend

Czytaj dalej

Zobacz więcej wpisów
Backend dla frontendowca: Cache, deployment, security

To trzecia część mojej małej serii „Backend dla frontendowca” i po fundamentach API oraz tematach real-time, webhooków i uwierzytelniania zostaje warstwa, która decyduje o tym, czy aplikacja wytrzyma prawdziwe użytkowanie: cache , kolejki, pliki, deployment, monitoring i bezpieczeństwo .

Maciej Sala

Maciej Sala

Founder StriveLab

REST API zasady projektowania oraz sprawdzone dobre praktyki

REST Representational State Transfer to styl architektoniczny, który mocno wpłynął na web, a w praktyce większość API, z którymi pracujesz jako frontend deweloper, to raczej HTTP API inspirowane REST niż "czysty REST" z podręcznika.

Maciej Sala

Maciej Sala

Founder StriveLab

Backend dla frontendowca: auth, real-time i integracje

Pierwsza część serii uporządkowała fundamenty: serwer, bazę danych, API i CORS. Teraz przechodzimy do obszarów, które zwykle pojawiają się chwilę później, gdy aplikacja przestaje być prostym CRUD-em: komunikacja w czasie rzeczywistym, webhooki, integracje zewnętrzne oraz autentykacja .

Maciej Sala

Maciej Sala

Founder StriveLab