To pierwsza część serii Backend dla frontendowca. Zaczynamy od fundamentów: 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” czasem oznacza kilka dni pracy.
Szersza perspektywa — architektura 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 request, load balancer kieruje go do jednego z serwerów API, serwer rozmawia z bazą, cache i storage, a potem wszystko wraca do jako odpowiedź.
To oczywiście uproszczenie, ale przydatne, ponieważ większość problemów, które frontend widzi jako „wolny ekran” albo „dziwny błąd”, dzieje się właśnie na styku tych warstw.
1. Serwer aplikacji jest sercem 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 (np. 3000),
- Przyjmuje HTTP requesty,
- Przetwarza logikę biznesową,
- Zwraca odpowiedzi.
Popularne opcje
Node.js + Express to częsty punkt wejścia dla osób z frontendu. Zostajesz w JavaScripcie, ale przenosisz część logiki na serwer:
Inne popularne (JS / TS):
- Fastify — znacznie szybsza alternatywa dla Expressa, która oferuje świetne wsparcie dla walidacji opartych na schematach (schema-first).
- Hono — lekki i ultraszybki framework stworzony z myślą o środowiskach typu edge (jak Cloudflare Workers czy Vercel Edge), ale doskonale radzący sobie również w klasycznym Node czy Bunie.
- NestJS — potężny, mocno ustrukturyzowany framework (tzw. opinionated), którego architektura mocno przypomina Angulara. To idealny wybór do skomplikowanych projektów i dla większych zespołów.
- Bun — to właściwie nie framework, a cały nowy, niezwykle szybki runtime (alternatywa dla Node.js). Posiada wbudowanego bundlera, test runnera i jest w dużym stopniu kompatybilny z API Node'a.
- Deno — kolejny nowoczesny runtime, stworzony przez twórcę Node.js. Natywnie wspiera TypeScript, z definicji stawia na bezpieczeństwo (mechanizm secure-by-default) i posiada świetnie przemyślaną bibliotekę standardową.
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 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:
- Routing — mapowanie URL → kod.
- Middleware — przetwarzanie requestów (auth, logging, CORS).
- Logika biznesowa — walidacja, obliczenia, reguły.
- Komunikacja z bazą — zapytania, ORM.
- Odpowiedzi — formatowanie JSON, statusy HTTP.
2. Bazy danych — tam, gdzie żyją dane
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. Jeśli chcesz zobaczyć, jak w praktyce podpiąć serwerową bazę Postgres do Next.js, sprawdź artykuł o Neon Postgres i Next.js — serverless bazie danych, która startuje w milisekundach.
SQL (relacyjne)
Dane w tabelach z relacjami:
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
NoSQL (nierelacyjne)
Dokumenty JSON-like:
Kiedy NoSQL: social media, real-time apps, dane dokumentowe lub bardzo nierówna struktura. To dobry wybór wtedy, gdy model danych bardziej przypomina dokument JSON niż zestaw stabilnych tabel.
Popularne: MongoDB, Firebase, DynamoDB
ORM — abstrakcja nad bazą
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 boilerplate'u. Nadal warto jednak rozumieć SQL, bo ORM nie zwalnia z myślenia o indeksach, JOIN-ach i kosztach zapytań.
Popularne ORM/query buildery: Prisma, Drizzle, Kysely, TypeORM (Node), SQLAlchemy (Python), Hibernate (Java), Eloquent (Laravel), Active Record (Rails).
Indeksy — szybkie wyszukiwanie
Indeks działa trochę jak spis treści w książce. Bez niego baza musi przejrzeć wiele wierszy, żeby znaleźć właściwy rekord. Z indeksem może przejść od razu do konkretnego miejsca. Dlatego zapytanie SELECT * FROM users WHERE email = '...' bez indeksu skanuje całą tabelę, a z indeksem na kolumnie email jest nieporównywalnie tańsze:
Kilka zasad wystarczy na start:
- Indeksuj kolumny używane w
WHERE,JOIN,ORDER BY - Composite index na
(user_id, created_at)przyspiesza listę postów użytkownika posortowaną po dacie - Każdy indeks kosztuje pamięć i spowalnia
INSERT/UPDATE— nie indeksuj na zapas EXPLAIN ANALYZEpokazuje, czy zapytanie używa indeksu
Transakcje — 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.
Jeśli druga operacja zawiedzie (timeout, błąd walidacji), pierwsza zostanie automatycznie cofnięta (ROLLBACK). Bez transakcji można skończyć z pieniędzmi, które wyparowały.
ACID — fundamentalne właściwości transakcji w SQL: Atomicity, Consistency, Isolation, Durability.
Problem N+1 — pułapka, którą widzi 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.
Dla 100 postów to różnica między jednym a 101 zapytaniami do bazy, a frontend odczuwa tylko skutki. Spinner trwa za długo, lista pojawia się z opóźnieniem, a zniecierpliwiony użytkownik zaczyna klikać ponownie. Choć problem objawia się w interfejsie, jego prawdziwe źródło tkwi w nieefektywnym sposobie pobierania danych.
Connection pool, czyli recykling połączeń
Każde otwarcie połączenia do bazy (TCP + handshake + auth) trwa kilkanaście milisekund. Aplikacja utrzymuje pool gotowych połączeń (np. 10), które są współdzielone między requestami.
Pułapka : Lambda / Vercel Functions mogą ponownie używać instancji, ale nie możesz na tym polegać. Przy większym ruchu platforma uruchamia wiele instancji równolegle, każda z własnym poolem, i baza może paść pod obciążeniem (too many connections). Rozwiązania: pgbouncer, Prisma Accelerate, Neon (built-in pooler), Supavisor (Supabase), serverless drivery (@neondatabase/serverless, @vercel/postgres).
Migracje — wersjonowanie schematu
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.
Każde środowisko (dev/staging/prod) wykonuje te same migracje w tej samej kolejności. Reguła: backward-compatible najpierw — najpierw dodaj kolumnę, potem deploy kodu, potem ewentualne usunięcie starej.
3. API — umowa między frontem 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 można retry'ować POST? 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.
GraphQL
GraphQL przesuwa część kontroli do klienta. Frontend nie dostaje jednej z góry ustalonej odpowiedzi, tylko sam deklaruje, jakie pola są mu potrzebne:
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, cache i obserwowalności.
tRPC
tRPC jest najbardziej atrakcyjne wtedy, gdy cały stack jest w TypeScripcie. Zamiast ręcznie synchronizować typy requestów i odpowiedzi, frontend korzysta z typów wyprowadzonych bezpośrednio z backendu.
Statusy HTTP — które widzi frontend
Status HTTP to pierwsza informacja, jaką frontend dostaje z API. Jeżeli wszystko poza 200 traktujesz tak samo, tracisz dużo kontekstu. 401, 403, 422 i 429 wymagają zupełnie różnych reakcji w UI.
| Kod | Znaczenie | Kiedy |
|---|---|---|
| 200 OK | Sukces | GET / PUT z odpowiedzią |
| 201 Created | Utworzono | POST tworzący zasób (zwróć URL nowego zasobu w Location) |
| 204 No Content | Sukces, brak ciała | DELETE bez odpowiedzi |
| 301 / 308 | Permanent redirect | URL zasobu się zmienił |
| 302 / 307 | Temporary redirect | Tymczasowe przekierowanie |
| 304 Not Modified | Cache jest aktualny | Po If-None-Match z ETag |
| 400 Bad Request | Klient wysłał śmieci | JSON się nie sparsował |
| 401 Unauthorized | Brak / złe uwierzytelnienie | Zaloguj się ponownie |
| 403 Forbidden | Zalogowany, ale brak uprawnień | Pokaż komunikat, nie redirect na login |
| 404 Not Found | Brak zasobu | |
| 409 Conflict | Konflikt stanu | Email już zajęty, optimistic lock |
| 410 Gone | Zasób usunięty na stałe | |
| 422 Unprocessable Entity | Walidacja nie przeszła | Pokaż błędy per pole |
| 429 Too Many Requests | Rate limit | Sprawdź Retry-After, cofnij się |
| 500 Internal Server Error | Wina backendu | Powiedz "spróbuj ponownie" |
| 502 / 503 / 504 | Gateway / unavailable / timeout | Infra padła, retry z backoff |
Struktura odpowiedzi błędów
Dobre API nie zwraca tylko stringa w stylu "Something went wrong", ponieważ frontend potrzebuje informacji, czy ma pokazać błąd pod konkretnym polem, przekierować do logowania, odczekać chwilę, czy dać użytkownikowi możliwość ponowienia akcji. Do tego służą strukturalne błędy. Standardem branżowym jest RFC 9457 Problem Details:
Dzięki temu frontend wie dokładnie:
- Który status dostał (decyzja: retry / login / pokaż błąd)
- Co dokładnie się stało (
title,detail) - Które pola są nieprawidłowe (mapowanie na komunikaty pod inputami)
- Jaki kod błędu (do logiki w UI — np.
INSUFFICIENT_FUNDSvsRATE_LIMITED)
Nigdy nie zwracaj stack trace w produkcji — to wektor ataku.
Walidacja inputu — nie ufaj frontendowi
Frontend może walidować formularz dla wygody użytkownika, ale backend musi walidować dane ponownie. Użytkownik może ominąć UI, wysłać request przez curl, zmodyfikować payload w DevTools albo użyć starej wersji aplikacji. Dlatego właśnie walidacja musi stać na granicy API.
Typowy przepływ:
- Parsujesz JSON i sprawdzasz
Content-Type. - Walidujesz kształt danych schematem.
- Walidujesz reguły biznesowe.
- Zwracasz
422z mapą błędów per pole.
Przykład z Zod:
Dla frontendowca najważniejsze jest to, żeby backend zwracał błędy w stabilnym formacie. Wtedy UI nie musi parsować losowych stringów i może mapować errors.email na konkretne pole formularza.
Paginacja — listy nigdy bez paginacji
Lista bez paginacji jest jednym z tych błędów, które długo pozostają niewidoczne. Przy 30 rekordach wszystko działa. Przy 30 000 rekordów endpoint zaczyna mielić, UI się zacina, a baza robi więcej pracy, niż powinna. Listy produkcyjne powinny mieć paginację od początku. Najczęściej spotkasz dwa wzorce:
Offset / limit — proste, intuicyjne, ale wolne na dużych zbiorach (baza musi pominąć N wierszy):
Cursor-based — szybkie, stabilne (nowe elementy nie psują stronicowania), używane przez Twitter, Stripe, GitHub:
Cursor to nieprzezroczysty token (często base64) oparty na id, created_at albo innym stabilnym kluczu ostatniego elementu strony.
Wybór: offset dla admin paneli z numeracją stron, cursor dla feedów (timeline, infinite scroll).
Filtrowanie i sortowanie
Filtrowanie i sortowanie powinny mieć przewidywalną konwencję. Dzięki temu frontend nie musi uczyć się osobnego stylu dla każdego endpointu.
status=published— filtersort=-created_at— minus = malejącofields=id,title— sparse fieldsets, oszczędzają transfer
Idempotency, czyli bezpieczne retry POST
Frontend często musi ponowić request. Ma to miejsce, kiedy użytkownik ma słaby internet, serwer odpowiada za wolno albo po drodze pojawia się timeout. Przy GET zwykle nie ma problemu, ale przy POST możesz przypadkiem stworzyć drugie zamówienie albo podwójną płatność. Idempotency key daje backendowi sposób na rozpoznanie, że to ta sama próba, a nie nowa operacja.
Backend:
- Zapisuje
key+ odpowiedź w bazie / Redis (TTL 24h). - Drugi request z tym samym kluczem zwraca tę samą odpowiedź, zamiast tworzyć nowy zasób.
- Stripe, GitHub, Shopify używają tego standardowo.
GET, PUT i DELETE są idempotentne z definicji HTTP, o ile backend implementuje je zgodnie z semantyką metody. POST nie jest idempotentny z definicji, a PATCH zależy od konkretnej operacji — set status=paid może być idempotentne, ale increment quantity by 1 już nie.
Wersjonowanie API
API żyje razem z produktem. Dzisiaj pole name wystarcza, jutro pojawia się displayName, za miesiąc dochodzi mobile app, a za pół roku integracja partnerska. Wersjonowanie jest sposobem na zmianę kontraktu bez rozbijania istniejących klientów.
W URL (proste, najczęstsze):
W headerze (czystsze URL):
Zero versioning (zawsze backward-compatible):
- Nigdy nie usuwasz pól,
- Nigdy nie zmieniasz znaczenia,
- Nowe rzeczy idą w nowych polach.
Stara wersja działa równolegle przez okres deprecation. Przy odpowiedziach z deprecated endpointów warto wysyłać header:
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, request body, response body i przykłady błędów.
W REST najczęściej robi się to przez OpenAPI:
Co to daje frontendowi:
- mniej zgadywania przy integracji,
- możliwość wygenerowania typów TypeScript,
- jasne statusy i formaty błędów,
- prostsze mockowanie API w testach,
- łatwiejsze wykrywanie breaking changes.
Jeśli pracujesz w TypeScripcie, alternatywą może być tRPC, typed route handlers albo wspólne schematy Zod/Valibot. Ważne nie jest narzędzie, tylko to, żeby kontrakt był sprawdzalny i aktualny.
BFF — 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ę:
Plusy:
- Ukrywa złożoność backendu — frontend nie musi wołać 5 serwisów.
- Mniej requestów z przeglądarki — jeden call zamiast pięciu.
- Trzyma sekrety po stronie serwera — klucze API nie wyciekają do klienta.
- Transformacje per platformę — inne API dla web, inne dla mobile.
Minusem jest dodatkowa warstwa do utrzymania i wtedy łatwo o nadużycie (BFF puchnie do całego backendu).
Observability — logi, request ID i debugowanie
Frontendowiec często widzi tylko komunikat: „coś poszło nie tak”. Backend powinien dawać zespołowi sposób na znalezienie konkretnego requestu w logach.
Minimum produkcyjne:
- request ID — unikalny identyfikator requestu zwracany w nagłówku, np.
X-Request-ID, - strukturalne logi — JSON z metodą, ścieżką, statusem, czasem odpowiedzi i user ID, jeśli jest dostępny,
- metryki — liczba requestów, p95/p99 latency, błędy 4xx/5xx,
- monitoring błędów — np. Sentry, Datadog, OpenTelemetry,
- bezpieczne logowanie — bez haseł, tokenów, pełnych danych kart i wrażliwych danych osobowych.
Przykładowa odpowiedź może zawierać request ID:
Dzięki temu użytkownik może wysłać supportowi identyfikator błędu, frontend może dołączyć go do raportu, a backend znajduje dokładny wpis w logach.
4. CORS — dlaczego "from origin" wkurza frontendowca
CORS jest frustrujący, bo wygląda jak błąd backendu, a tak naprawdę zaczyna się w przeglądarce. Przeglądarka domyślnie blokuje odczyt odpowiedzi między różnymi originami (https://app.example.com → https://api.example.com). To element modelu bezpieczeństwa weba, czyli Same-Origin Policy.
Jak to działa
Dla prostych requestów (GET / HEAD / POST z Content-Type: application/x-www-form-urlencoded | multipart/form-data | text/plain) przeglądarka wysyła request od razu. Dopiero gdy dostanie odpowiedź, sprawdza, czy backend pozwolił ją udostępnić frontendowi:
Jeśli nagłówka brakuje albo origin się nie zgadza, przeglądarka odrzuca odpowiedź. Request faktycznie poszedł do serwera, ale JavaScript w UI nie dostanie danych.
Dla skomplikowanych requestów (POST z Content-Type: application/json, niestandardowe nagłówki, PUT / DELETE / PATCH) przeglądarka najpierw robi preflight — request OPTIONS:
Backend musi odpowiedzieć:
Dopiero po sukcesie preflight przeglądarka wysyła właściwy request. Każdy preflight to dodatkowy round-trip — Max-Age cachuje odpowiedź na X sekund.
Konfiguracja w Express
Typowe pułapki
Access-Control-Allow-Origin: *nie działa zcredentials: 'include'. Musisz wskazać konkretny origin.- Cookies nie polecą bez:
credentials: 'include'po stronie frontu iAccess-Control-Allow-Credentials: truepo stronie backendu. - Localhost vs production — najczęstszy bug: środowisko dev ma inne origin niż prod. Konfiguracja per env.
- Subdomena ma znaczenie —
app.example.comiapi.example.comto różne originy. - Port ma znaczenie —
localhost:3000≠localhost:5173.
CORS to nie autoryzacja
CORS kontroluje, czy JavaScript w przeglądarce może odczytać odpowiedź z innego origina. Curl, aplikacja mobilna i request server-side mogą wołać Twoje 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.
Checklist przed integracją endpointu
Zanim frontend zacznie podpinać endpoint, najlepiej ustalić kilka rzeczy:
- Jaki jest pełny URL, metoda HTTP i wymagane nagłówki?
- Czy endpoint wymaga sesji, tokenu, ról albo konkretnych uprawnień?
- Jak wygląda request body i które pola są opcjonalne?
- Jakie statusy może zwrócić endpoint:
200,201,204,401,403,409,422,429,5xx? - Jaki jest stabilny format błędu i czy błędy formularza są mapowane per pole?
- Czy lista ma paginację, sortowanie, filtrowanie i limit maksymalny?
- Czy operację można retry'ować i czy wymaga idempotency key?
- Czy odpowiedź można buforować w cache'u i jak długo?
- Czy endpoint zwraca request ID do debugowania?
- 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 → możliwa przyczyna backendowa
| Objaw w interfejsie | Możliwa przyczyna backendowa |
|---|---|
| Lista długo pokazuje spinner | Brak indeksu, N+1, brak paginacji, wolne JOIN-y |
| Formularz pokazuje ogólny błąd | Brak strukturalnych błędów per pole |
| Użytkownik jest wyrzucany do logowania | 401, 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ęciu | Brak idempotency key albo blokady po stronie UI |
| Request działa w Postmanie, ale nie w UI | CORS, cookies, credentials, różny origin |
| Infinite scroll gubi lub duplikuje dane | Offset pagination przy zmieniającym się zbiorze |
| Produkcja działa wolniej niż staging | Brak poolingu, zimne starty, inna skala danych |
