Część 3 z 3
Suchary.com od środka: growth hacking na poziomie kodu
Jak przepisałem serwis z PHP 5.x na Next.js 16 z Supabase, zmigrowałem 496 MB bazy MySQL do PostgreSQL i wdrożyłem techniczne mechanizmy wzrostu: Core Web Vitals, crawl budget, cursor pagination i pragmatyczny monitoring.
Ten artykuł jest częścią case study o przebudowie suchary.com — polskiego serwisu z żartami kupionego na Allegro za 800 zł w 2012 roku. Pełną historię transformacji znajdziesz w artykule głównym: Jak kupiłem serwis za 800 zł i zarabia dla mnie pasywnie od 14 lat.
Stara architektura — PHP z 2012 roku
IPS CMS od iprosoft.pl
Kiedy w 2012 roku kupowałem suchary.com, serwis działał na IPS CMS-ie od iprosoft.pl — polskim silniku stworzonym specjalnie pod strony humorystyczne, całkiem popularnym w latach 2012–2015. Oferował dokładnie to, czego taki serwis potrzebował: posty z głosowaniem, komentarze, tagi i panel administracyjny. Jak wiele niszowych produktów tamtej ery, projekt w pewnym momencie po cichu umarł — zniknęły aktualizacje, zniknęła dokumentacja, a PHP 5.x zostało wpisane w jego DNA już na zawsze.
Co było pod maską
Całość mieściła się mniej więcej w pięćdziesięciu plikach PHP. Żadnego composer.json, żadnego menedżera zależności — biblioteki łączyło się przez require_once i uzupełniało zmiennymi globalnymi, co w 2012 roku było standardem, a w 2026 brzmi jak archeologia. Baza danych stała na MySQL-u z silnikiem MyISAM, co oznaczało brak transakcji i brak kluczy obcych, a hasła użytkowników przechowywane były w md5() bez soli. Każde zapytanie SQL budowane było przez konkatenację stringów, bez cienia prepared statements — czyli z otwartym zaproszeniem do SQL injection na każdym kroku.
Nowy stack — Next.js 16 i Supabase
Kryteria wyboru
Wybierając technologię pod remont, miałem w głowie trzy priorytety. Po pierwsze, szybkość developmentu — jeden programista plus AI, cel: zmieścić się w tygodniu realnej pracy. Po drugie, minimalne utrzymanie — tam, gdzie się dało, sięgałem po usługi zarządzane, żeby nie budować sobie kolejnej pracy na etacie. I po trzecie, orientacja na SEO — SSR, structured data i szybki TTFB, bo ruch organiczny to w tym projekcie krwiobieg.
Stary kontra nowy
Warstwa | 2012 | 2026 |
|---|---|---|
Backend | PHP 5.x (IPS CMS) | Next.js 16 (App Router, RSC) |
Baza | MySQL/MyISAM | PostgreSQL (Supabase) |
Auth | MD5 bez soli | Supabase Auth (bcrypt) |
Frontend | jQuery 1.9 + Smarty | React 19 + Tailwind v4 |
UI Kit | brak | shadcn/ui (New York, Zinc) |
Hosting | CyberFolks shared | Docker na Hetzner VPS |
CDN/SSL | brak | Cloudflare |
Analityka | GA + Hotjar | Umami (self-hosted) |
Deploy | FTP | Docker build + nginx |
Dlaczego Next.js
React Server Components pobierają dane bezpośrednio na serwerze, co oznacza, że klient dostaje gotowy, wyrenderowany HTML, a Google widzi treść od razu, bez konieczności czekania na rehydratację:
Trzy zapytania w Promise.all lecą równolegle, a tryb output: "standalone" buduje samodzielny serwer Node.js gotowy do zapakowania w obraz Dockera — bez zależności od katalogu node_modules na produkcji.
Dlaczego Supabase
Supabase to managed PostgreSQL z Auth, Storage, Realtime i Row Level Security w jednym pakiecie — i właśnie ta konsolidacja była dla mnie kluczowa. RLS działa na poziomie bazy, a nie aplikacji, hasła trzyma w bcrypt zamiast w MD5, a TypeScriptowe SDK wygląda tak samo po stronie Node.js, jak i w przeglądarce, co oszczędza sporo przełączania kontekstów.
Migracja — 496 MB dumpa MySQL do PostgreSQL
Parsowanie bez MySQL
Dump z CyberFolks miał 496 MB i zamiast stawiać pod to osobną instancję MySQL-a tylko po to, żeby po chwili ją wyłączyć, napisałem parser bezpośrednio w TypeScripcie (scripts/migrate.ts). Parser ma dwa poziomy: splitRows rozbija INSERT INTO ... VALUES na krotki, pilnując jednocześnie głębokości nawiasów i stanu stringów (escape'owane apostrofy, NULL, wieloliniowe INSERT-y), a parseRow rozbiera pojedynczą krotkę na tablicę gotowych do wstawienia wartości.
Mapowanie tabel
Stara tabela | Nowa tabela | Transformacja |
|---|---|---|
users | auth.users + profiles | UUID, bcrypt, trigger |
upload_post | posts | Enum mapping |
upload_text | post_content | FK do posts |
upload_post_info | posts.votes_up/down | Agregaty do kolumn |
tagi | tags | Deduplication by slug |
tagi_rowfile | post_tags | UUID junction table |
upload_comments | comments | Two-pass (self-referencing FK) |
Pułapki migracji
Migracja tej wielkości zawsze ma swoje uroki i tutaj też kilka się trafiło. Najbardziej uciążliwe były escape'owane cudzysłowy — w MySQL-u występowały w dwóch wariantach (backslash-escape i double-quote), a każdy z nich wymagał innego potraktowania. Nieprawidłowe daty pokroju 0000-00-00 00:00:00, które w MySQL-u przechodziły bez mrugnięcia okiem, w PostgreSQL-u rzucają wyjątek, więc musiałem je podmieniać na fallback w postaci now(). Do tego dochodziły różnice w kodowaniu (utf8mb4 kontra UTF-8), limit rozmiaru requestu w Supabase (~1 MB, więc batch ustawiłem na 500 rekordów) oraz komentarze z self-referencing FK — te ostatnie musiałem wstawić w dwóch przejściach: najpierw bez parent_id, a potem zaktualizować relacje, żeby nie wpaść w kurę-i-jajko. Tagi z kolei wymagały deduplikacji po slugu, bo w 5563 tagach „Programista" i „programista" funkcjonowały osobno, choć semantycznie były jednym.
W finale sprowadziło się to do jednego polecenia — npx tsx scripts/migrate.ts — które po kilku minutach przeniosło 2586 postów, 170 użytkowników, 5563 tagów, 72 komentarze i 10 351 relacji post-tag.
Architektura bazy PostgreSQL
Schemat
W schemacie świadomie poszedłem na kilka konkretnych decyzji. vote_score zostało generated column, żeby nie liczyć różnicy w aplikacji przy każdym renderze. legacy_id z ograniczeniem UNIQUE zapewnia kompatybilność wsteczną z dotychczasowymi URL-ami (o czym za chwilę szerzej). search_vector typu tsvector daje mi pełnotekstowe wyszukiwanie wprost w PostgreSQL-u, bez potrzeby stawiania Elasticsearcha. Statusy i typy opakowałem w enumy na poziomie bazy, żeby nie ufać aplikacji w kwestii poprawności tych pól.
Denormalizacja
Pola typu posts.votes_up/down, posts.comment_count czy tags.post_count są świadomie zdenormalizowane. Czytam je setki razy częściej, niż zapisuję, a bez tego uproszczenia strona główna wymagałaby trzydziestu jeden zapytań; z denormalizacją załatwia się to jednym SELECT-em.
Spójność utrzymują cztery triggery (wszystkie oznaczone SECURITY DEFINER): handle_vote_change używa COUNT(*) zamiast inkrementacji, co daje odporność na race conditions, handle_comment_count przelicza licznik przy INSERT i DELETE na comments, handle_tag_count robi to samo dla post_tags, a handle_new_user tworzy profil automatycznie przy rejestracji. Dzięki temu cała logika spójności siedzi w bazie, a nie w aplikacji, co znaczy, że nawet gdybym kiedyś napisał drugiego klienta (np. aplikację mobilną), nie musiałbym duplikować tej logiki.
Row Level Security
Każda tabela ma włączone RLS, a polityki nakładają się na siebie zgodnie z logiką biznesową. Przykład łączenia polityk dla postów:
Efekt jest taki, że nawet gdyby ktoś trafił na prawidłowy UUID posta, Supabase zwróci pustą odpowiedź dla nieautoryzowanego zapytania. Bezpieczeństwo przestaje być odpowiedzialnością warstwy aplikacji i zostaje sprowadzone do warstwy bazy, gdzie nie da się go łatwo obejść.
Techniczne mechanizmy wzrostu
Core Web Vitals — czynnik rankingowy od 2021
W maju 2021 Google oficjalnie włączył Core Web Vitals do algorytmu rankingowego i od tego momentu te trzy metryki wpływają bezpośrednio na to, jak wysoko strona pokazuje się w wynikach wyszukiwania.
Metryka | Co mierzy | Cel | Jak osiągnąłem |
|---|---|---|---|
LCP (Largest Contentful Paint) | Czas renderowania największego elementu | < 2.5s | RSC — HTML renderowany na serwerze, zero client-side fetch na initial load |
INP (Interaction to Next Paint) | Responsywność na interakcje | < 200ms | Optimistic UI na głosowaniu — state update przed response z API |
CLS (Cumulative Layout Shift) | Stabilność layoutu | < 0.1 | Skeleton loading z ustalonymi wymiarami, aspect-ratio na obrazkach, |
Dla porównania — stara strona na PHP i jQuery miała LCP powyżej sześciu sekund, CLS przekraczał 0.5, a INP był po prostu niemierzalny, bo jQuery blokował główny wątek przeglądarki na tyle długo, że narzędzia nie nadążały. Nowa wersja w PageSpeed Insights świeci się na zielono zarówno na mobile, jak i na desktopie.
Przełożenie na przychód jest dwukierunkowe. Google wprost potwierdza, że CWV to sygnał rankingowy, więc lepsze pozycje przekładają się na większy ruch i więcej wyświetleń reklam. Ale jest też drugi, mniej oczywisty efekt: szybsza strona zmniejsza współczynnik odrzuceń, bo użytkownicy, którzy nie muszą czekać sześciu sekund na załadowanie, chętniej klikają w kolejne podstrony.
Optymalizacja crawl budgetu
Google przeznacza ograniczony budżet na crawlowanie każdej domeny, a przy 2586 stronach treści plus tagi, profile, panel admina i endpointy API większość tego budżetu mogłaby zostać zjedzona przez strony, które i tak nie mają wartości SEO.
Strategia robots.txt odcina te, które niczego nie wnoszą:
Efekt? Crawl budget skierowany na 2586 stron z realną treścią plus bloga i strony tematyczne. Według Search Console crawl rate wzrósł po wdrożeniu dwukrotnie.
Dodatkowo dynamiczna sitemapa z priorytetami dostarcza Google'owi gotową mapę zamiast zmuszać go do samodzielnego odkrywania struktury:
Linkowanie wewnętrzne — przepływ autorytetu
Każdy link wewnętrzny przekazuje część autorytetu strony linkującej do strony docelowej, co przy 2586 stronach treści oznacza, że odpowiednia struktura linków wewnętrznych ma bezpośredni wpływ na to, które strony Google uznaje za najważniejsze.
W praktyce zaimplementowałem to na kilka sposobów. Sidebar „Popularne żarty" pokazuje top 10 po vote_score, dzięki czemu każdy żart w feedzie linkuje do stron o najwyższym autorytecie w serwisie. Sekcja „Powiązane żarty" na stronie pojedynczego żartu wyciąga inne posty o wspólnych tagach, budując w ten sposób klastry treści, które Google traktuje jako sygnał topic authority. Breadcrumbs pojawiają się na każdej stronie w schemacie Strona główna → Tag → Żart, w podwójnej formie: jako BreadcrumbList w JSON-LD (dla Google) i jako widoczne linki (dla użytkownika). Artykuły blogowe linkują do konkretnych żartów jako przykładów, co tworzy naturalny przepływ autorytetu z długich form do krótkich.
Infinite scroll z cursor-based pagination
Klasyczny offset w stylu LIMIT 10 OFFSET 100 ma złożoność O(N), bo baza musi pominąć sto wierszy, zanim dojdzie do tych, o które pytamy. Cursor-based pagination wykorzystuje indeks B-tree i działa w O(1):
Sztuczka z .limit(limit + 1) polega na tym, że pobieram jedenaście rekordów zamiast dziesięciu. Jeśli baza faktycznie zwróci jedenaście — wiem, że jest kolejna strona; jeśli dziesięć lub mniej — to koniec listy. Oszczędzam dzięki temu osobne zapytanie COUNT(*), które przy dużych tabelach potrafi być zaskakująco drogie.
Ładowanie wyzwala IntersectionObserver z rootMargin: "200px", czyli kolejna partia pobiera się, zanim użytkownik dotrze do końca aktualnej listy. W efekcie praktycznie nigdy nie widzi loadera, a tym samym nie ma momentu, w którym mógłby się zniechęcić i wyjść.
Korzyści metryczne są dwie. Po pierwsze, infinite scroll zwiększa liczbę stron na sesję, a każda odsłona to dodatkowa ekspozycja reklam. Po drugie, zmniejsza bounce rate, bo użytkownik nie musi podejmować świadomej decyzji o kliknięciu „następnej strony".
Conversion Rate Optimization
CRO to systematyczne podnoszenie odsetka użytkowników, którzy wykonują pożądaną akcję. Na suchary.com kluczowymi konwersjami są: głosowanie, udostępnienie, zapis na newsletter i dodanie własnego żartu. Kilka konkretnych technik, które wdrożyłem.
Progressive disclosure na żartach w formacie Q&A. Żarty typu pytanie-odpowiedź mają puentę ukrytą pod przyciskiem „Pokaż odpowiedź". To nie jest feature — to mechanizm CRO. Kliknięcie to mikrointerakcja, która zwiększa zaangażowanie; po odkryciu puenty rośnie prawdopodobieństwo głosowania (bo użytkownik „zainwestował" w żart kliknięcie), a sam format w naturalny sposób zachęca do udostępniania w stylu „zobacz, czy zgadniesz".
Optimistic UI na głosowaniu. Kliknięcie głosu natychmiast aktualizuje licznik w interfejsie, zanim w ogóle wróci odpowiedź z API. Efekt użytkownik widzi w mniej niż 50 ms zamiast po 200–500 ms, a szybsza pętla feedbacku przekłada się wprost na większą liczbę oddanych głosów:
Losowy żart jako fallback. Pusta strona wyników wyszukiwania to ślepy zaułek — użytkownik wychodzi. Zamiast komunikatu „Brak wyników" podsuwam przycisk „Pokaż losowy żart", który ratuje sesję i wyraźnie zbija bounce rate.
Reklamy w feedzie co 8 żartów. Stała AD_EVERY_N_JOKES = 8 to kompromis między przychodem a komfortem czytania — wystarczająco rzadko, żeby nie irytować, wystarczająco często, żeby realnie generować przychód. Reklamy renderują się jako jeden ze slotów feedu i mogą być zapełnione AdSense'em, własną reklamą kupioną przez Stripe lub — jeśli nic nie pasuje — pustym placeholderem.
Structured data — rich snippets
Na każdej stronie osadzam JSON-LD dopasowany do typu treści. Globalnie leci Organization i WebSite z SearchAction, na stronie pojedynczego żartu dokładam CreativeWork z tytułem, autorem i tekstem, InteractionCounter dla głosów i komentarzy oraz BreadcrumbList. Blog dostaje schemat Article plus BreadcrumbList, a strona główna — ItemList z najnowszymi żartami.
Rich snippets podnoszą CTR w wynikach wyszukiwania, bo wzbogacone wyniki z gwiazdkami, liczbą komentarzy czy okruszkami nawigacyjnymi po prostu przyciągają wzrok bardziej niż zwykły, jednoliniowy link.
Zachowanie legacy URL equity
Najważniejsza zasada SEO przy migracji brzmi: nie trać tego, co Google już wie o twojej stronie. Każdy zmieniony URL to potencjalnie utracona pozycja i złamany backlink, który prowadzi nagle w próżnię.
Zrobiłem to na kilka sposobów równolegle. Stare adresy w stylu /123/suchar.html zostały przeniesione na /${legacy_id}/${slug}, dzięki czemu Google widzi dokładnie te same numery, co przez ostatnie czternaście lat. Catch-all route w Next.js obsługuje stare wzorce i wykonuje 301 redirect na nowe adresy. Każda strona ma explicit canonical URL, więc nawet jeśli ktoś trafi przez parametry czy warianty, Google wie, który adres jest tym „właściwym". Nginx dodatkowo przekierowuje legacy pattern /page/* na stronę główną. Efekt: link z forum z 2014 roku nadal działa i nadal przekazuje autorytet.
Mobile-first i bottom navigation
Ponad 60 procent polskiego ruchu to mobile, więc układ zaprojektowałem odwrotnie niż w 2012 roku — trzy kolumny na desktopie, jedna na mobile, a do tego pływający bottom nav z pięcioma elementami (Góra, Ulubione, Losowy, Szukaj, Dark Mode). Klasy fixed bottom-4, z-50, lg:hidden wystarczają, żeby uzyskać zachowanie znane z natywnych aplikacji.
Dark mode obsługuje next-themes z klasami dark: w Tailwindzie, a preferencja zapamiętywana jest w localStorage, żeby nie migotała przy każdym przeładowaniu.
Analityka — view tracking na infinite scrollu
Umami zamiast Google Analytics
Kryterium | GA4 | Umami |
|---|---|---|
Cookies | Wymaga zgody GDPR | Brak cookies |
Hosting | Google (USA) | Self-hosted |
Złożoność | Setki raportów | Page views, referrers, urządzenia |
Wydajność | ~90 KB gtag.js | ~5 KB skrypt |
Cookie banner | Wymagany | Niepotrzebny |
Problem: infinite scroll kontra page views
Przy infinite scrollu użytkownik ładuje technicznie jedną stronę, ale widzi w trakcie sesji 50 żartów — i standardowy pomiar page views po prostu przestaje mieć sens. Rozwiązałem to w trzech warstwach: IntersectionObserver z progiem widoczności 50 procent wyłapuje moment, w którym żart jest realnie przeczytany, dwa zbiory (pending i sent) deduplikują zdarzenia w sesji, a debounce co dwie sekundy zbiera je w jedną paczkę przed wysłaniem. Backend odbiera taką paczkę i wykonuje atomowy batch increment w PostgreSQL-u.
Parametr keepalive: true w fetch() sprawia, że request dokończy się nawet wtedy, gdy użytkownik w międzyczasie zamknie kartę. Po stronie API pilnuję walidacji — limit pięćdziesięciu ID na request oraz regex sprawdzający format UUID — a funkcja increment_view_counts wykonuje jeden UPDATE ... WHERE id = ANY(post_ids), zamiast lecieć po rekordzie na raz.
Sens tego całego zachodu jest prosty: view count per post to dodatkowy sygnał jakości, który pozwala promować popularne żarty w feedzie i podejmować świadome decyzje co do strategii treści.
Deploy — Docker, Hetzner, Cloudflare
Ścieżka requestu
Multi-stage build
Dockerfile ma trzy etapy. Pierwszy (deps) odpala npm ci i stanowi kacheowalną warstwę, która zmienia się tylko przy aktualizacji zależności. Drugi (builder) wykonuje next build, z zmiennymi NEXT_PUBLIC_* przekazanymi jako --build-arg, żeby zostały zinlinowane w wygenerowanym JS-ie. Trzeci (runner) wykorzystuje standalone output i odpala aplikację jako non-root user. Finalny obraz waży około 200 MB zamiast 1,5 GB, jak w naiwnym wariancie.
Zmienne środowiskowe są rozdzielone świadomie: NEXT_PUBLIC_* trafiają do build-time i zostają w JS-ie, a reszta — sekrety, klucze serwisowe — przez --env-file w runtime'ie.
Deploy: dwie komendy
Przestój ograniczony do kilku sekund, bez CI/CD i bez GitHub Actions. Przy dwóch, trzech deployach tygodniowo takie narzędzia byłyby zwyczajnie przerostem formy nad treścią.
Koszty
Usługa | Koszt | Co daje |
|---|---|---|
Hetzner VPS (udział) | ~5 EUR/mies. | Serwer, Docker, nginx |
Cloudflare | 0 EUR | CDN, SSL, DDoS, DNS |
Supabase | 0 EUR (free) | PostgreSQL, Auth, Storage |
Domena | ~10 EUR/rok | suchary.com |
Łącznie | ~6 EUR/mies. | Pełna infrastruktura |
Panel admina
Panel siedzi pod /admin/* i nie pokazuje się w publicznej nawigacji. Strażnik autoryzacji opiera się na supabase.auth.getUser() plus fladze is_admin w tabeli profiles, a w razie niezgodności wykonywany jest cichy redirect("/"), bez ujawniania, co poszło nie tak.
Sekcje panelu rozłożone są według naturalnego workflow. Dashboard pokazuje pięć liczników pobieranych równolegle przez Promise.all z flagą head: true, żeby nie ciągnąć niepotrzebnych danych. Moderacja trzyma kolejkę postów i komentarzy z pełnym zestawem akcji: zatwierdź, odrzuć, edytuj inline, wykonaj korektę przez AI, oznacz jako 18+, usuń — a każda z tych akcji korzysta z optymistycznego updatu w UI. Blog dostał rich HTML editor, picker zdjęć stockowych z Pexels, metadane SEO oraz przypisanie do topic clustra. Kategorie to klasyczny CRUD z auto-slugiem, który poprawnie obsługuje polskie znaki diakrytyczne. Klastry zarządzają topic clusterami z pillar article ID. Reklamy agregują zamówienia ze statusami płatności i moderacji, podglądem banera oraz akcjami pokroju zwrotu przez Stripe. Ustawienia pozwalają przełączać cookie banner i wstrzykiwać skrypty w <head> i <body> (Umami, AdSense, CMP) — a ponieważ trzymane są jako key-value w tabeli site_settings, zmiana wchodzi od ręki, bez redeployu.
Monitoring — pragmatyczny model jakości
Monitoring rozbity jest na cztery warstwy, każda o innej roli.
Sentry (@sentry/nextjs) łapie błędy w czasie rzeczywistym na poziomie clienta, serwera i edge. Typowy repertuar to timeouty do Supabase, hydration mismatches i nieudane fetch-e — każdy nowy alert kończy się szybką naprawą, zanim zdąży urosnąć.
Umami pełni rolę behavioral monitoringu. Skok w bounce rate oznacza, że coś się zepsuło w UX. Page views per session spadające do 1.0 mówią mi wprost, że infinite scroll przestał działać. Nagły drop w referrerach to sygnał ostrzegawczy przed możliwą deindeksacją w Google.
Search Console obsługuje monitoring SEO — indeksację, CTR per strona, pozycje na konkretne frazy i Core Web Vitals mierzone w polu, a nie tylko syntetycznie. Spadek CTR na jakimś URL-u to alarm, żeby przyjrzeć się title i description; spadek indeksacji — pretekst, żeby sprawdzić robots.txt i sitemapę.
Manual QA po każdym deployu sprowadza się do dwuminutowej rutyny: otworzyć stronę, kliknąć żart, zagłosować, sprawdzić mobile. Prosto i skutecznie.
Proporcjonalność narzędzi do skali
To jest coś, o czym lubię przypominać sobie przy każdej pokusie dodawania kolejnego narzędzia. Content site z przychodem rzędu kilkuset złotych rocznie nie potrzebuje tego samego pipeline'u jakości co SaaS z SLA — i niewdrożenie pewnych rzeczy też jest decyzją inżynierską. Unit testy na Server Components (pobierz dane, wyrenderuj HTML) byłyby dłuższe od testowanego kodu. E2E na trzech flow'ach zajmie więcej czasu niż manual test, który i tak trwa dwie minuty. Staging? Rollback z Dockerem trwa pięć sekund. CI/CD? Dwie komendy w terminalu robią dokładnie to, co trzeba, bez warstwy pośredniczącej.
Koszty utrzymania tych wszystkich narzędzi przy obecnej skali przewyższałyby ich wartość. A pierwsza zasada pragmatycznej architektury brzmi: buduj do skali, którą masz, a nie do tej, którą chciałbyś mieć.
Komentarze
Ładowanie...