Jak uratowałem europejską firmę oszukaną przez agencję. Migracja z PHP 5.6 i jQuery na React, Django, PostgreSQL, Redis i Rust. Kod, metryki i lekcje.
PL

Jak uratowałem firmę oszukaną przez agencję: przebudowa na nowoczesny stos technologiczny

5.00 /5 - (31 głosów )
Ostatnio zweryfikowano: 1 maja 2026
18min czytania
Case study
Full-stack developer

Zadzwonił do mnie w piątek wieczorem. Głos spokojny, ale słyszałem w nim zmęczenie człowieka, który od tygodni walczy z problemem, na który nie ma wpływu. Prowadzi firmę usługową obsługującą klientów w czterech krajach europejskich. Zapłacił agencji za profesjonalną platformę internetową. Dostał coś, co wyglądało jak profesjonalna platforma. Pod spodem kryła się katastrofa.

Ze względu na umowę poufności nie mogę ujawnić nazwy firmy ani branży. Mogę natomiast opowiedzieć dokładnie, co zastałem, co zrobiłem i dlaczego wybrałem takie, a nie inne technologie. Ta historia to przestroga dla każdego, kto zleca budowę platformy zewnętrznej agencji.


#Co zastałem po audycie platformy od agencji

Pierwszy krok to zawsze audyt. Nie oceniam, nie krytykuję. Zbieram fakty. Po trzech dniach analizy miałem pełny obraz.

Spaghetti code na PHP 5.6: agencja nie użyła żadnego frameworka. Cała platforma to monolityczny kod proceduralny w PHP 5.6 (koniec wsparcia w 2018 roku) z zapytaniami SQL wklejanymi bezpośrednio w szablony HTML. Żadnego ORM-a, żadnej warstwy abstrakcji, żadnego routera. Pliki po 3000 linii mieszające logikę biznesową z prezentacją.

// Znaleziony kod -- zapytanie SQL bezpośrednio w szablonie (zanonimizowany)
<?php
$result = mysql_query("SELECT * FROM services 
    WHERE category = '" . $_GET['cat'] . "' 
    ORDER BY id DESC");
// SQL injection -- brak jakiejkolwiek walidacji danych wejściowych
while ($row = mysql_fetch_assoc($result)) {
    echo "<div class='service'>";
    echo "<h2>" . $row['title'] . "</h2>"; // XSS -- brak escapowania
    echo "<p>" . $row['description'] . "</p>";
    echo "</div>";
}
?>

MySQL 5.5 bez indeksów: baza danych z 47 tabelami, żadna nie miała indeksów poza kluczami głównymi. Zapytanie listujące usługi z filtrami wykonywało full table scan na 200 000 rekordach, a średni czas odpowiedzi wynosił 4.7 sekundy.

jQuery 1.x + Bootstrap 3: frontend z 2014 roku. Dwanaście plików jQuery ładowanych na każdej stronie, w tym trzy różne wersje biblioteki. Brak minifikacji, brak bundlera, brak tree-shaking. Łączna waga skryptów: 2.8 MB.

FTP jako “deployment”: brak repozytorium Git, brak CI/CD, brak środowiska staging. Agencja wrzucała pliki bezpośrednio przez FTP na serwer produkcyjny. Żadnego systemu kontroli wersji. Brak testów.

Zerowe bezpieczeństwo: hasła użytkowników przechowywane w MD5 bez soli. Sesje w plikach na serwerze współdzielonym. SQL injection w 23 miejscach. XSS w formularzach. Brak CSRF tokenów. Brak HTTPS na panelu logowania.

#Metryki zastane

MetrykaWartośćOcena
PageSpeed (mobile)18Krytyczny
LCP12.4sKrytyczny
INP1100msKrytyczny
CLS0.52Krytyczny
TTFB4.7sKrytyczny
Waga strony11.2 MBNadmierna
Czas odpowiedzi API4.7s (avg)Krytyczny
Ruch organicznySpadek 72% r/rKrytyczny
Wykryte podatności23 SQL injection, 14 XSSKrytyczny

Najgorsze było to, że klient nie wiedział o żadnym z tych problemów. Agencja przez rok wysyłała mu raporty z “optymalizacji”, które nie miały pokrycia w rzeczywistości.


#Dlaczego wybrałem ten stos technologiczny

Decyzja o architekturze docelowej to najważniejszy moment projektu. Klient miał uzasadnione obawy. Poprzednia agencja obiecała “nowoczesne rozwiązanie” i dostarczyła kod z 2014 roku. Musiałem wybrać technologie, które rozwiążą konkretne problemy, nie te które są akurat modne.

Przeanalizowałem wymagania i dopasowałem narzędzia do zadań:

Python + Django (backend API): klient potrzebował solidnego backendu z panelem administracyjnym, uwierzytelnianiem, walidacją danych i REST API. Django daje to wszystko z pudełka. Django REST Framework to dojrzały, stabilny ekosystem z doskonałą dokumentacją. Klient obsługuje 4 rynki europejskie. Django ma wbudowaną internacjonalizację.

PostgreSQL (baza danych): migracja z MySQL 5.5 na PostgreSQL to nie kaprys. PostgreSQL oferuje lepsze indeksy (GIN, GiST do wyszukiwania pełnotekstowego), lepsze typy danych (JSONB, arrays), dojrzałe partycjonowanie tabel i niezawodne transakcje ACID. Dla 200 000 rekordów z wielojęzycznym wyszukiwaniem pełnotekstowym to naturalny wybór.

Redis (cache i kolejki): czas odpowiedzi API z 4.7 sekundy musiał spaść poniżej 100 milisekund. Redis cachuje wyniki zapytań, przechowuje sesje użytkowników i obsługuje kolejki zadań asynchronicznych (Celery). Jedno narzędzie, trzy krytyczne funkcje.

React + TypeScript (frontend interaktywny): dashboard klienta, wyszukiwarka z filtrami, formularze wieloetapowe. To wszystko wymaga reaktywnego UI. React z TypeScript daje typowane komponenty, doskonałe narzędzia deweloperskie i ogromny ekosystem bibliotek.

Rust (mikroserwis wydajnościowy): indeksowanie wyszukiwania 200 000 rekordów w 4 wersjach językowych, przetwarzanie plików CSV/Excel od klientów, transformacje danych. Te zadania wymagały surowej wydajności. Rust przetwarza indeks wyszukiwania w 1.8 sekundy zamiast 47 sekund w starej implementacji PHP. To nie jest różnica procentowa. To różnica o rząd wielkości.

Astro (strona marketingowa): strona główna, blog, oferta, strony usług. To treści statyczne, które nie potrzebują JavaScriptu. Astro generuje czysty HTML z zerowymi kosztami runtime. Interaktywne elementy (wyszukiwarka, formularz kontaktowy) działają jako izolowane wyspy React. Jako profesjonalny programista Astro wybrałem ten framework dla strony publicznej.

Architektura docelowa:

┌─────────────────────────────────────────────────────┐
│                  Cloudflare CDN                       │
├──────────────┬───────────────┬────────────────────────┤
│  Astro SSG   │  React SPA    │  Django REST API       │
│  (marketing) │  (dashboard)  │  (backend)             │
│  HTML/CSS    │  TypeScript   │  Python 3.12           │
├──────────────┴───────────────┴────────────────────────┤
│                                                       │
│  ┌─────────────┐  ┌──────────┐  ┌──────────────────┐ │
│  │ PostgreSQL   │  │  Redis   │  │  Rust service    │ │
│  │ (primary DB) │  │  (cache) │  │  (search index,  │ │
│  │              │  │  (queue) │  │   data processing│ │
│  └─────────────┘  └──────────┘  └──────────────────┘ │
│                                                       │
│  ┌─────────────────────────────────────────────────┐ │
│  │  Python AI pipeline (content processing, NLP)    │ │
│  └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

#Backend API w Django REST Framework

Serce nowej platformy to Django z Django REST Framework. Zbudowałem API obsługujące wielojęzyczny katalog usług, system zapytań od klientów, uwierzytelnianie JWT i panel administracyjny.

# services/models.py -- model usługi z wielojęzycznością
from django.db import models
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField

class Service(models.Model):
    slug = models.SlugField(max_length=200, unique=True)
    category = models.ForeignKey(
        'Category', on_delete=models.PROTECT, related_name='services'
    )
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

class ServiceTranslation(models.Model):
    service = models.ForeignKey(
        Service, on_delete=models.CASCADE, related_name='translations'
    )
    language = models.CharField(max_length=5, choices=[
        ('pl', 'Polski'), ('en', 'English'),
        ('de', 'Deutsch'), ('fr', 'Français'),
    ])
    title = models.CharField(max_length=200)
    description = models.TextField()
    meta_title = models.CharField(max_length=70)
    meta_description = models.CharField(max_length=160)
    search_vector = SearchVectorField(null=True)

    class Meta:
        unique_together = ['service', 'language']
        indexes = [
            GinIndex(fields=['search_vector']),
            models.Index(fields=['language', 'service']),
        ]


# services/serializers.py -- serializator z walidacją
from rest_framework import serializers

class ServiceSerializer(serializers.ModelSerializer):
    translations = ServiceTranslationSerializer(many=True, read_only=True)
    category_name = serializers.CharField(
        source='category.name', read_only=True
    )

    class Meta:
        model = Service
        fields = [
            'id', 'slug', 'category_name',
            'is_active', 'translations', 'created_at',
        ]


# services/views.py -- widoki API z cache Redis
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend

class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Service.objects.filter(
        is_active=True
    ).select_related(
        'category'
    ).prefetch_related(
        'translations'
    )
    serializer_class = ServiceSerializer
    filter_backends = [DjangoFilterBackend, filters.SearchFilter]
    filterset_fields = ['category__slug']
    search_fields = ['translations__title', 'translations__description']

    @method_decorator(cache_page(60 * 15))  # Cache 15 minut
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

Konfiguracja Redis jako backend cache i broker kolejek Celery:

# settings.py -- konfiguracja Redis
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/0',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
            'SERIALIZER': 'django_redis.serializers.json.JSONSerializer',
            'CONNECTION_POOL_KWARGS': {'max_connections': 50},
        },
        'KEY_PREFIX': 'platform',
        'TIMEOUT': 900,  # 15 minut domyślnie
    }
}

# Sesje w Redis (szybsze niż baza danych)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'

# Celery z Redis jako broker
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/1'
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/2'
CELERY_TASK_SERIALIZER = 'json'

Wpływ po wdrożeniu Django + PostgreSQL + Redis: średni czas odpowiedzi API spadł z 4.7 sekundy do 45 milisekund. Zapytania z cache Redis obsługiwane w 3-5 milisekund.


#Mikroserwis Rust do indeksowania wyszukiwania

Najciekawsze wyzwanie techniczne to wyszukiwarka. Klient ma katalog 200 000 rekordów w 4 wersjach językowych. Stara implementacja PHP wykonywała LIKE '%term%' na MySQL bez indeksów: 47 sekund na zapytanie. Nieużywalne.

PostgreSQL z GIN indexami i tsvector rozwiązał problem dla standardówych zapytań. Ale klient potrzebował też:

  • wyszukiwania z tolerancją na literówki (fuzzy matching),
  • filtrowania po wielu atrybutach jednocześnie z natychmiastowymi wynikami,
  • przebudowy indeksu po imporcie danych z plików CSV/Excel.

Do tych zadań zbudowałem mikroserwis w Rust z biblioteką Tantivy (odpowiednik Apache Lucene dla Rust):

// search-service/src/indexer.rs -- indeksowanie wyszukiwania w Rust
use tantivy::{
    schema::*, doc, Index, IndexWriter,
    tokenizer::NgramTokenizer,
};
use serde::Deserialize;
use std::time::Instant;

#[derive(Deserialize)]
pub struct ServiceRecord {
    pub id: i64,
    pub slug: String,
    pub title: String,
    pub description: String,
    pub category: String,
    pub language: String,
    pub attributes: Vec<String>,
}

pub struct SearchIndexer {
    index: Index,
    schema: Schema,
}

impl SearchIndexer {
    pub fn new(index_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let mut schema_builder = Schema::builder();

        schema_builder.add_i64_field("id", STORED | INDEXED);
        schema_builder.add_text_field("slug", STORED);
        schema_builder.add_text_field("title", TEXT | STORED);
        schema_builder.add_text_field("description", TEXT | STORED);
        schema_builder.add_text_field("category", STRING | STORED);
        schema_builder.add_text_field("language", STRING | STORED);
        schema_builder.add_text_field("attributes", TEXT | STORED);
        // N-gram field for fuzzy/partial matching
        schema_builder.add_text_field("title_ngram", TEXT);

        let schema = schema_builder.build();
        let index = Index::create_in_dir(index_path, schema.clone())?;

        // Register n-gram tokenizer for typo tolerance
        let ngram_tokenizer = NgramTokenizer::new(2, 4, false)
            .expect("Failed to create ngram tokenizer");
        index
            .tokenizers()
            .register("ngram", ngram_tokenizer);

        Ok(Self { index, schema })
    }

    pub fn build_index(
        &self,
        records: Vec<ServiceRecord>,
    ) -> Result<usize, Box<dyn std::error::Error>> {
        let start = Instant::now();
        let mut writer: IndexWriter = self.index.writer(128_000_000)?; // 128MB buffer

        let title_field = self.schema.get_field("title").unwrap();
        let description_field = self.schema.get_field("description").unwrap();
        let title_ngram_field = self.schema.get_field("title_ngram").unwrap();

        let count = records.len();
        for record in records {
            writer.add_document(doc!(
                self.schema.get_field("id").unwrap() => record.id,
                self.schema.get_field("slug").unwrap() => record.slug,
                title_field => record.title.clone(),
                description_field => record.description,
                self.schema.get_field("category").unwrap() => record.category,
                self.schema.get_field("language").unwrap() => record.language,
                self.schema.get_field("attributes").unwrap() =>
                    record.attributes.join(" "),
                title_ngram_field => record.title,
            ))?;
        }

        writer.commit()?;
        let duration = start.elapsed();
        println!(
            "Indexed {} records in {:.2}s",
            count,
            duration.as_secs_f64()
        );

        Ok(count)
    }
}

HTTP API w Rust z frameworkiem Actix-web:

// search-service/src/main.rs -- API wyszukiwania
use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct SearchQuery {
    q: String,
    lang: Option<String>,
    category: Option<String>,
    limit: Option<usize>,
}

#[derive(Serialize)]
struct SearchResult {
    id: i64,
    slug: String,
    title: String,
    excerpt: String,
    category: String,
    score: f32,
}

async fn search(
    query: web::Query<SearchQuery>,
    indexer: web::Data<SearchIndexer>,
) -> HttpResponse {
    let limit = query.limit.unwrap_or(20);
    let lang = query.lang.as_deref().unwrap_or("pl");

    let results = indexer.search(
        &query.q, lang, query.category.as_deref(), limit
    );

    HttpResponse::Ok().json(results)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let indexer = SearchIndexer::new("./search_index")
        .expect("Failed to create indexer");
    let indexer_data = web::Data::new(indexer);

    HttpServer::new(move || {
        App::new()
            .app_data(indexer_data.clone())
            .route("/search", web::get().to(search))
            .route("/health", web::get().to(|| async {
                HttpResponse::Ok().body("ok")
            }))
    })
    .bind("127.0.0.1:8081")?
    .run()
    .await
}

Wyniki benchmarku mikroserwisu Rust:

OperacjaStary PHPNowy RustPoprawa
Budowa indeksu (200k rekordów)47s1.8s26x szybciej
Wyszukiwanie proste4.7s2ms2350x szybciej
Wyszukiwanie z filtrami8.3s5ms1660x szybciej
Fuzzy matching (literówki)Brak8msNowa funkcja
Zużycie pamięci512 MB84 MB6x mniej

Rust nie był wyborem “bo jest modny”. Był wyborem, bo dla tego konkretnego zadania: przetwarzania 200 000 rekordów z indeksowaniem n-gramowym i fuzzy matchingiem, daje wydajność nieosiągalną dla interpreterów.


#Frontend React z interaktywnym dashboardem

Dashboard klienta wymagał reaktywnego UI: tabele z sortowaniem, filtry wielopoziomowe, formularze wieloetapowe, wykresy z danymi w czasie rzeczywistym. React z TypeScript to naturalny wybór.

// src/components/ServiceSearch.tsx -- wyszukiwarka z filtrami
import { useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';

interface SearchResult {
  id: number;
  slug: string;
  title: string;
  excerpt: string;
  category: string;
  score: number;
}

interface SearchFilters {
  category: string | null;
  language: string;
}

export function ServiceSearch({ locale }: { locale: string }) {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState<SearchFilters>({
    category: null,
    language: locale,
  });

  const debouncedQuery = useDebounce(query, 300);

  const { data: results, isLoading } = useQuery<SearchResult[]>({
    queryKey: ['search', debouncedQuery, filters],
    queryFn: async () => {
      const params = new URLSearchParams({
        q: debouncedQuery,
        lang: filters.language,
        ...(filters.category && { category: filters.category }),
      });
      const res = await fetch(`/api/search?${params}`);
      return res.json();
    },
    enabled: debouncedQuery.length >= 2,
    staleTime: 5 * 60 * 1000, // 5 minut cache
  });

  const handleCategoryChange = useCallback((category: string | null) => {
    setFilters(prev => ({ ...prev, category }));
  }, []);

  return (
    <div className="search-container">
      <div className="relative">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder={locale === 'pl' ? 'Szukaj usług...' : 'Search services...'}
          className="w-full px-4 py-3 rounded-lg border border-gray-200
                     dark:border-gray-700 bg-white dark:bg-gray-800
                     focus:ring-2 focus:ring-emerald-500 focus:outline-none"
        />
        {isLoading && (
          <div className="absolute right-3 top-1/2 -translate-y-1/2">
            <span className="animate-spin h-5 w-5 border-2
                           border-emerald-500 border-t-transparent rounded-full
                           inline-block" />
          </div>
        )}
      </div>

      {results && results.length > 0 && (
        <div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
          {results.map((result) => (
            <a
              key={result.id}
              href={`/${locale}/${result.slug}/`}
              className="block p-4 rounded-lg border border-gray-100
                         dark:border-gray-700 hover:border-emerald-500
                         transition-colors"
            >
              <span className="text-xs font-medium text-emerald-600
                              dark:text-emerald-400">
                {result.category}
              </span>
              <h3 className="mt-1 font-semibold text-gray-900
                            dark:text-white">
                {result.title}
              </h3>
              <p className="mt-2 text-sm text-gray-600 dark:text-gray-400
                           line-clamp-2">
                {result.excerpt}
              </p>
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

Komponent React jest osadzony w stronie Astro jako interaktywna wyspa:

---
// src/pages/[lang]/services.astro -- strona usług
import { ServiceSearch } from '../../components/ServiceSearch';
import Layout from '../../layouts/Layout.astro';
---

<Layout title="Usługi">
  <section class="services-hero">
    <h1>Nasze usługi</h1>
  </section>

  <!-- Wyspa React -- ładuje się przy scrollu, nie blokuje reszty strony -->
  <ServiceSearch client:visible locale={Astro.params.lang} />
</Layout>

Dzięki architekturze Islands strona marketingowa ładuje 0 KB JavaScriptu domyślnie. Komponent wyszukiwarki (38 KB gzipped) ładuje się dopiero gdy użytkownik przewinie do sekcji.


#Pipeline AI do przetwarzania treści

Migracja 1200 stron treści z brudnego HTML na czysty Markdown wymagała automatyzacji. Wiele stron miało niekompletne metadane SEO, brakujące opisy, nieoptymalne nagłówki. Zamiast poprawiać je ręcznie, zbudowałem pipeline w Pythonie wykorzystujący niestandardówy model językowy.

Pipeline działał w trzech fazach:

# ai_content_pipeline.py -- przetwarzanie treści z AI
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor

import markdownify

@dataclass
class ContentAnalysis:
    entities: list[dict] = field(default_factory=list)
    meta_description: str = ''
    heading_issues: list[str] = field(default_factory=list)
    word_count: int = 0
    is_thin: bool = False
    suggested_internal_links: list[str] = field(default_factory=list)

def clean_legacy_html(raw_html: str) -> str:
    """Faza 1: Czyszczenie brudnego HTML z legacy platformy."""
    # Usuwanie inline styles, komentarzy warunkowych IE, pustych tagów
    cleaned = re.sub(r'style="[^"]*"', '', raw_html)
    cleaned = re.sub(r'<!--\[if.*?\]>.*?<!\[endif\]-->', '', cleaned,
                     flags=re.DOTALL)
    cleaned = re.sub(r'<(div|span|p)[^>]*>\s*</\1>', '', cleaned)
    cleaned = re.sub(r'&nbsp;', ' ', cleaned)
    return cleaned

def convert_to_markdown(html: str) -> str:
    """Faza 2: Konwersja HTML na Markdown."""
    return markdownify.markdownify(
        html,
        heading_style="ATX",
        strip=['script', 'style', 'iframe', 'object', 'embed'],
        convert=['h1', 'h2', 'h3', 'h4', 'p', 'a', 'img',
                 'ul', 'ol', 'li', 'strong', 'em', 'table'],
    )

def analyze_with_ai(content: str, model_client) -> ContentAnalysis:
    """Faza 3: Analiza treści za pomocą niestandardowego modelu AI."""
    prompt = f"""Przeanalizuj poniższą treść strony internetowej.
Zwróć JSON z polami:
- entities: lista obiektów {{name, type, relevance_score}}
- meta_description: optymalny opis meta (max 155 znaków, po polsku)
- heading_issues: lista problemów że strukturą nagłówków
- word_count: liczba słów
- suggested_internal_links: sugerowane frazy do linkowania wewnętrznego

Treść:
{content[:4000]}"""

    response = model_client.generate(prompt, max_tokens=1024)
    data = json.loads(response.text)

    return ContentAnalysis(
        entities=data.get('entities', []),
        meta_description=data.get('meta_description', ''),
        heading_issues=data.get('heading_issues', []),
        word_count=data.get('word_count', 0),
        is_thin=data.get('word_count', 0) < 300,
        suggested_internal_links=data.get('suggested_internal_links', []),
    )

def process_batch(
    content_dir: Path, model_client, max_workers: int = 4
) -> dict:
    """Przetwarzanie wsadowe z wielowątkowością."""
    stats = {'processed': 0, 'thin': 0, 'entities': 0}

    def process_file(md_file: Path) -> ContentAnalysis:
        content = md_file.read_text(encoding='utf-8')
        return analyze_with_ai(content, model_client)

    files = list(content_dir.glob('*.md'))
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        results = executor.map(process_file, files)
        for analysis in results:
            stats['processed'] += 1
            if analysis.is_thin:
                stats['thin'] += 1
            stats['entities'] += len(analysis.entities)

    return stats

Pipeline przetworzył 1247 stron w 4 godziny na własnej infrastrukturze, nie w chmurze, nie przez API zewnętrzne. Znalazł 3400 unikalnych encji, wygenerował brakujące meta opisy dla 680 stron i zidentyfikował 89 stron z thin content wymagających rozbudowy.

Model AI działał na dedykowanym serwerze z GPU, bez korzystania z AWS, Azure ani żadnego cloud-managed rozwiązania. Pełna kontrola nad danymi klienta, zero przesyłania treści do zewnętrznych API.


#Migracja danych z MySQL do PostgreSQL

Migracja bazy danych z MySQL 5.5 do PostgreSQL wymagała nie tylko przeniesienia danych, ale fundamentalnej przebudowy schematu. Stary schemat nie miał relacji, indeksów ani ograniczeń integralności.

-- Migracja schematu: od chaosu do porządku

-- Stary MySQL (brak relacji, brak indeksów)
-- CREATE TABLE services (id INT AUTO_INCREMENT, title VARCHAR(255), ...);
-- CREATE TABLE categories (id INT AUTO_INCREMENT, name VARCHAR(100), ...);
-- Brak FOREIGN KEY, brak INDEX poza PK

-- Nowy PostgreSQL z prawidłową strukturą
CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    slug VARCHAR(200) UNIQUE NOT NULL,
    parent_id INTEGER REFERENCES categories(id),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE services (
    id SERIAL PRIMARY KEY,
    slug VARCHAR(200) UNIQUE NOT NULL,
    category_id INTEGER NOT NULL REFERENCES categories(id),
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE service_translations (
    id SERIAL PRIMARY KEY,
    service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
    language VARCHAR(5) NOT NULL,
    title VARCHAR(200) NOT NULL,
    description TEXT NOT NULL,
    meta_title VARCHAR(70) NOT NULL,
    meta_description VARCHAR(160) NOT NULL,
    search_vector TSVECTOR,
    UNIQUE(service_id, language)
);

-- Indeksy dla wydajnego wyszukiwania
CREATE INDEX idx_translations_search ON service_translations
    USING GIN(search_vector);
CREATE INDEX idx_translations_lang ON service_translations(language);
CREATE INDEX idx_services_category ON services(category_id)
    WHERE is_active = true;

-- Trigger automatycznej aktualizacji search_vector
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
        setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_search_vector
    BEFORE INSERT OR UPDATE ON service_translations
    FOR EACH ROW EXECUTE FUNCTION update_search_vector();

#Migracja SEO: przekierowania i dane strukturalne

Najkrytyczniejszy element migracji to zachowanie pozycji w Google. Pomimo tego, że stara platforma traciła ruch, wciąż miała setki zaindeksowanych URL-i i dziesiątki backlinków.

Zmapowałem każdy stary URL do nowego i wdrożyłem przekierowania 301 na poziomie Cloudflare Workers:

// redirects.ts -- mapa przekierowań że starej platformy
const redirectMap: Record<string, string> = {
  '/uslugi.php?id=1': '/pl/uslugi/konsulting/',
  '/services.php?id=1': '/en/services/consulting/',
  '/index.php?page=about': '/pl/o-nas/',
  '/kontakt.php': '/pl/kontakt/',
  '/leistungen.php': '/de/dienstleistungen/',
  // ... 963 przekierowania wygenerowane automatycznie
};

export function handleRedirects(url: URL): Response | null {
  // Sprawdź dokładne dopasowanie
  const exactMatch = redirectMap[url.pathname + url.search];
  if (exactMatch) {
    return new Response(null, {
      status: 301,
      headers: { Location: exactMatch },
    });
  }

  // Sprawdź dopasowanie bez query string
  const pathMatch = redirectMap[url.pathname];
  if (pathMatch) {
    return new Response(null, {
      status: 301,
      headers: { Location: pathMatch },
    });
  }

  return null;
}

Wdrożyłem kompletne dane strukturalne Schema.org z hreflang dla każdej wersji językowej. Stara platforma nie miała żadnych danych strukturalnych. Google nie rozumiał, czym jest strona.


#Wyniki po 4 miesiącach

MetrykaPrzedPoZmiana
PageSpeed (mobile)1899+450%
LCP12.4s0.3s-98%
INP1100ms22ms-98%
CLS0.520.01-98%
TTFB4.7s0.03s-99%
Waga strony11.2 MB0.28 MB-97%
Czas odpowiedzi API4700ms45ms-99%
Wyszukiwanie (200k rekordów)47s2ms23500x
Ruch organicznyBazowy+340%Wzrost
Zapytania od klientów~2/tydzień~14/tydzień+600%
Podatności bezpieczeństwa370Wyeliminowane

Pełny stos technologiczny projektu:

  • Backend API: Python 3.12, Django 5, Django REST Framework, Celery
  • Baza danych: PostgreSQL 16 z indeksami GIN i wyszukiwaniem pełnotekstowym
  • Cache i kolejki: Redis 7 (cache, sesje, broker Celery)
  • Mikroserwis wyszukiwania: Rust, Actix-web, Tantivy
  • Frontend interaktywny: React 19, TypeScript, TanStack Query, Tailwind CSS
  • Strona marketingowa: Astro 5 z wyspami React
  • AI pipeline: Python, niestandardówy model językowy na dedykowanym GPU
  • Deployment: Cloudflare Pages + Workers, GitHub Actions CI/CD
  • Monitoring: Sentry, Prometheus, Grafana

#Czego nauczył mnie ten projekt

Nie osądzaj. Diagnozuj. Klient przyszedł zraniony złym doświadczeniem z agencją. Ostatnie, czego potrzebował, to kolejny “ekspert” mówiący mu, jak bardzo jest źle. Zamiast tego przedstawiłem fakty w formie raportu, wyjaśniłem ryzyko i zaproponowałem plan działania z jasnym harmonogramem.

Dobierz narzędzie do zadania, nie zadanie do narzędzia. Django do API, React do interaktywnego UI, Rust do przetwarzania danych, Astro do statycznych stron. Każda technologia rozwiązuje konkretny problem. Monolityczny framework do wszystkiego to recepta na kompromisy.

AI to narzędzie, nie magia. Pipeline AI przetworzył 1247 stron w 4 godziny, co ręcznie zajęłoby tygodnie. Ale każdy wynik wymagał ludzkiej weryfikacji. AI generowało sugestie, człowiek podejmował decyzje. Niestandardówy model na własnym serwerze daje pełną kontrolę nad danymi klienta.

Rust uzasadnia się w konkretnych przypadkach. Nie napisałem całej platformy w Rust. Napisałem w Rust jeden mikroserwis, który przetwarza 200 000 rekordów. I tam różnica wydajności jest o rząd wielkości. Reszta systemu działa doskonale w Pythonie i TypeScript.

Migracja SEO to nie opcja. To obowiązek. Bez mapowania URL i przekierowań 301 klient straciłby resztki ruchu organicznego. Dzięki prawidłowej migracji ruch wzrósł o 340% w 4 miesiące.


#Potrzebujesz ratunku po złym doświadczeniu z agencją?

Jeśli Twoja platforma została zbudowana na przestarzałych technologiach, jest wolna, niebezpieczna lub po prostu nie działa jak powinna, skontaktuj się z WPPoland. Przeprowadzę bezpłatny audyt zastany i przedstawię plan naprawczy z jasnym harmonogramem i zakresem prac.

Każdy projekt ratunkowy zaczyna się od jednego telefonu. Ten klient zadzwonił w piątek wieczorem. W poniedziałek rano miał raport. Osiem tygodni później miał platformę, z której jest dumny.

Następny krok

Przekuj artykuł w realne wdrożenie

Pod tym wpisem dokładam linki, które domykają intencję użytkownika i prowadzą dalej w strukturze serwisu.

Chcesz wdrożyć ten temat na swojej stronie?

Jeśli zależy Ci na widoczności w Google i systemach AI, mogę przygotować architekturę treści, FAQ, schema i linkowanie pod GEO, AEO i SEO.

Powiązany klaster

Sprawdź inne usługi WordPress i bazę wiedzy

Wzmocnij swój biznes dzięki profesjonalnemu wsparciu technicznemu w kluczowych obszarach ekosystemu WordPress.

FAQ do artykułu

Często zadawane pytania

Najważniejsze odpowiedzi, które pomagają wdrożyć temat w praktyce.

SEO-ready GEO-ready AEO-ready 4 Q&A
Ile trwa migracja platformy z przestarzałego stosu na nowoczesną architekturę?
Czas zależy od skali projektu i jest ustalany indywidualnie po audycie. Prosta platforma to 4-6 tygodni. Złożony system z wieloma integracjami, wielojęzycznością i komponentami interaktywnymi: 8-14 tygodni.
Dlaczego Rust zamiast Node.js lub Go do mikroserwisów?
Rust oferuje wydajność porównywalną z C/C++ przy gwarancjach bezpieczeństwa pamięci. Do zadań wymagających przetwarzania dużych zbiorów danych, indeksowania wyszukiwania i transformacji plików Rust daje przewagę wydajności o rząd wielkości nad Node.js.
Czy migracja na nowy stos wpływa na pozycje w Google?
Prawidłowo przeprowadzona migracja z mapowaniem URL i przekierowaniami 301 chroni istniejące pozycje. Poprawa Core Web Vitals po migracji zazwyczaj powoduje wzrost ruchu organicznego o 30-50 procent w ciągu 3 miesięcy.
Co zrobić, gdy agencja oszukała i zostawiła platformę bez dokumentacji?
Pierwszy krok to audyt zastany: sprawdzenie kodu, licencji, bezpieczeństwa i wydajności. Następnie odzyskanie danych i treści z istniejącej instalacji. Dopiero potem planowanie migracji na nową platformę z pełną dokumentacją.

Potrzebujesz FAQ dopasowanego do branży i rynku? Przygotujemy wersję pod Twoje cele biznesowe.

Porozmawiajmy

Polecane artykuły

Astro 5 czy Next.js 15 - który framework wybrać w 2026? Dogłębne porównanie wydajności, architektury, zastosowań i kiedy używać każdego do projektów Headless WordPress.
wordpress

Astro 5 vs Next.js 15: kompletne porównanie techniczne 2026

Astro 5 czy Next.js 15 - który framework wybrać w 2026? Dogłębne porównanie wydajności, architektury, zastosowań i kiedy używać każdego do projektów Headless WordPress.

Dowiedz się, kiedy przebudowa strony internetowej jest konieczna. 7 konkretnych sygnałów technicznych i biznesowych, które oznaczają, że Twoja strona wymaga modernizacji w 2026 roku.
wordpress

Kiedy przebudować stronę internetową? 7 sygnałów, że czas na modernizację

Dowiedz się, kiedy przebudowa strony internetowej jest konieczna. 7 konkretnych sygnałów technicznych i biznesowych, które oznaczają, że Twoja strona wymaga modernizacji w 2026 roku.

WordPress 7.0 z AI Client kontra Astro 6 po akwizycji Cloudflare. Porównanie prędkości, kosztów, SEO i bezpieczeństwa. Opinia po 20 latach jako programista WP - kiedy migrować, a kiedy zostać.
wordpress

WordPress 7.0 vs Astro 6 po akwizycji Cloudflare - kto wygrywa w 2026?

WordPress 7.0 z AI Client kontra Astro 6 po akwizycji Cloudflare. Porównanie prędkości, kosztów, SEO i bezpieczeństwa. Opinia po 20 latach jako programista WP - kiedy migrować, a kiedy zostać.