Han ringte meg på en fredagskveld. Stemmen var rolig, men jeg hørte trettheten i den, trettheten til en mann som i ukevis har kjempet mot et problem han ikke kan kontrollere. Han driver et tjenesteselskap som betjener kunder i fire europeiske land. Han betalte et byrå for en profesjonell nettplattform. Han fikk noe som så ut som en profesjonell plattform. Under overflaten lå katastrofen.
På grunn av en taushetserklæring kan jeg ikke avsløre selskapets navn eller bransje. Jeg kan imidlertid fortelle nøyaktig hva jeg fant, hva jeg gjorde, og hvorfor jeg valgte akkurat disse teknologiene. Denne historien er en advarsel til alle som setter ut plattformbygging til et eksternt byrå.
Hva jeg fant etter revisjonen av byråets plattform
Det første steget er alltid revisjon. Jeg dømmer ikke, jeg kritiserer ikke. Jeg samler fakta. Etter tre dagers analyse hadde jeg et fullstendig bilde.
Spaghetti-kode på PHP 5.6: byrået brukte ikke noe rammeverk. Hele plattformen var monolitisk prosedyrekode i PHP 5.6 (støtten opphørte i 2018) med SQL-spørringer limt direkte inn i HTML-maler. Ingen ORM, ingen abstraksjonslag, ingen ruter. Filer på 3 000 linjer som blandet forretningslogikk med presentasjon.
// Funnet kode -- SQL-spørring direkte i mal (anonymisert)
<?php
$result = mysql_query("SELECT * FROM services
WHERE category = '" . $_GET['cat'] . "'
ORDER BY id DESC");
// SQL injection -- ingen validering av inndata
while ($row = mysql_fetch_assoc($result)) {
echo "<div class='service'>";
echo "<h2>" . $row['title'] . "</h2>"; // XSS -- ingen escaping
echo "<p>" . $row['description'] . "</p>";
echo "</div>";
}
?>
MySQL 5.5 uten indekser: database med 47 tabeller, ingen hadde indekser utover primærnøkler. En spørring som listet tjenester med filtre utførte full table scan på 200 000 oppføringer, med gjennomsnittlig svartid på 4,7 sekunder.
jQuery 1.x + Bootstrap 3: frontend fra 2014. Tolv jQuery-filer lastet på hver side, inkludert tre forskjellige versjoner av biblioteket. Ingen minifisering, ingen bundler, ingen tree-shaking. Total skriptstørrelse: 2,8 MB.
FTP som “utrulling”: ingen Git-repository, ingen CI/CD, ingen staging-miljø. Byrået lastet opp filer direkte via FTP til produksjonsserveren. Ingen versjonskontroll. Ingen tester.
Null sikkerhet: brukerpassord lagret i MD5 uten salt. Sesjoner i filer på delt server. SQL injection på 23 steder. XSS i skjemaer. Ingen CSRF-tokens. Ingen HTTPS på innloggingspanelet.
Funnet målinger
| Metrikk | Verdi | Vurdering |
|---|---|---|
| PageSpeed (mobil) | 18 | Kritisk |
| LCP | 12,4s | Kritisk |
| INP | 1100ms | Kritisk |
| CLS | 0,52 | Kritisk |
| TTFB | 4,7s | Kritisk |
| Sidestørrelse | 11,2 MB | Overdreven |
| API-svartid | 4,7s (gjennomsnitt) | Kritisk |
| Organisk trafikk | Fall 72% år/år | Kritisk |
| Oppdagede sårbarheter | 23 SQL injection, 14 XSS | Kritisk |
Det verste var at kunden ikke visste om noen av disse problemene. Byrået hadde i ett år sendt ham rapporter om “optimaliseringer” som ikke hadde rot i virkeligheten.
Hvorfor jeg valgte dette teknologistakket
Beslutningen om målarkitektur er det viktigste øyeblikket i prosjektet. Kunden hadde berettiget bekymring: det forrige byrået hadde lovet en “moderne løsning” og levert kode fra 2014. Jeg måtte velge teknologier som løste konkrete problemer, ikke de som tilfeldigvis er på moten.
Jeg analyserte kravene og tilpasset verktøyene til oppgavene:
Python + Django (backend-API): kunden trengte en solid backend med administrasjonspanel, autentisering, datavalidering og REST-API. Django leverer alt dette ut av esken. Django REST Framework er et modent, stabilt økosystem med utmerket dokumentasjon. Kunden betjener 4 europeiske markeder, og Django har innebygd internasjonalisering.
PostgreSQL (database): migrering fra MySQL 5.5 til PostgreSQL er ikke et innfall. PostgreSQL tilbyr bedre indekser (GIN, GiST for fulltekstsøk), bedre datatyper (JSONB, arrays), moden tabellpartisjonering og pålitelige ACID-transaksjoner. For 200 000 oppføringer med flerspråklig fulltekstsøk er det et naturlig valg.
Redis (cache og køer): API-svartiden på 4,7 sekunder måtte ned under 100 millisekunder. Redis cacher spørringsresultater, lagrer brukersesjoner og håndterer asynkrone oppgavekøer (Celery). Ett verktøy, tre kritiske funksjoner.
React + TypeScript (interaktiv frontend): klientdashboard, søkemotor med filtre, flertrinns skjemaer. Alt dette krever reaktivt brukergrensesnitt. React med TypeScript gir typede komponenter, utmerkede utviklerverktøy og et enormt bibliotekøkosystem.
Rust (ytelsesmikrotjeneste): indeksering av søk for 200 000 oppføringer i 4 språkversjoner, behandling av CSV/Excel-filer fra klienter, datatransformasjoner. Disse oppgavene krevde rå ytelse. Rust behandler søkeindeksen på 1,8 sekunder i stedet for 47 sekunder i den gamle PHP-implementasjonen. Dette er ikke en prosentvis forskjell, det er en størrelsesorden.
Astro (markedsføringsside): hjemmeside, blogg, tilbud, tjenestesider. Dette er statisk innhold som ikke trenger JavaScript. Astro genererer ren HTML uten runtime-kostnader. Interaktive elementer (søkemotor, kontaktskjema) kjører som isolerte React-øyer. Som dedikert Astro-utvikler var dette rammeverket det naturlige valget for den offentlige nettsiden.
Målarkitektur:
┌─────────────────────────────────────────────────────┐
│ 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 i Django REST Framework
Hjertet i den nye plattformen er Django med Django REST Framework. Jeg bygde et API som håndterer flerspråklig tjenestekatalog, system for kundehenvendelser, JWT-autentisering og administrasjonspanel.
# services/models.py -- tjenestemodell med flerspråklighet
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 -- serialisator med validering
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 -- API-visninger med Redis-cache
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)
Konfigurasjon av Redis som cache-backend og Celery-kømegler:
# 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'
Effekt etter distribusjon av Django + PostgreSQL + Redis: gjennomsnittlig API-svartid falt fra 4,7 sekunder til 45 millisekunder. Spørringer fra Redis-cache håndteres på 3-5 millisekunder.
Rust-mikrotjeneste for søkeindeksering
Den mest interessante tekniske utfordringen var søkemotoren. Kunden har en katalog med 200 000 oppføringer i 4 språkversjoner. Den gamle PHP-implementasjonen utførte LIKE '%term%' på MySQL uten indekser: 47 sekunder per spørring. Ubrukelig.
PostgreSQL med GIN-indekser og tsvector løste problemet for standardspørringer. Men kunden trengte også:
- søk med toleranse for skrivefeil (fuzzy matching),
- filtrering på flere attributter samtidig med øyeblikkelige resultater,
- gjenoppbygging av indeks etter dataimport fra CSV/Excel-filer.
For disse oppgavene bygde jeg en mikrotjeneste i Rust med biblioteket Tantivy (tilsvarende Apache Lucene for 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 i Rust med Actix-web-rammeverket:
// 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
}
Benchmark-resultater for Rust-mikrotjenesten:
| Operasjon | Gammel PHP | Ny Rust | Forbedring |
|---|---|---|---|
| Bygg indeks (200k oppføringer) | 47s | 1,8s | 26x raskere |
| Enkelt søk | 4,7s | 2ms | 2350x raskere |
| Søk med filtre | 8,3s | 5ms | 1660x raskere |
| Fuzzy matching (skrivefeil) | Ingen | 8ms | Ny funksjon |
| Minnebruk | 512 MB | 84 MB | 6x mindre |
Rust var ikke et valg fordi det er på moten. Det var et valg fordi for denne konkrete oppgaven, behandling av 200 000 oppføringer med n-gram-indeksering og fuzzy matching, gir det ytelse som ikke er oppnåelig for tolkere.
React-frontend med interaktivt dashboard
Klientdashboardet krevde reaktivt brukergrensesnitt: tabeller med sortering, flernivåfiltre, flertrinns skjemaer, diagrammer med sanntidsdata. React med TypeScript er det naturlige valget.
// 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>
);
}
React-komponenten er innebygd i Astro-siden som en interaktiv øy:
---
// 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>
Takket være Islands-arkitekturen laster markedsføringssiden 0 KB JavaScript som standard. Søkemotorkomponenten (38 KB gzipped) lastes først når brukeren scroller til seksjonen.
AI-pipeline for innholdsbehandling
Migrering av 1 200 innholdssider fra skittent HTML til ren Markdown krevde automatisering. Mange sider hadde ufullstendig SEO-metadata, manglende beskrivelser og ikke-optimale overskrifter. I stedet for å rette dem manuelt bygde jeg en pipeline i Python som brukte en tilpasset språkmodell.
Pipelinen arbeidet i tre faser:
# 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' ', ' ', 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 ze 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
Pipelinen behandlet 1 247 sider på 4 timer på egen infrastruktur, ikke i skyen, ikke via ekstern API. Den fant 3 400 unike entiteter, genererte manglende metabeskrivelser for 680 sider og identifiserte 89 sider med tynt innhold som krevde utbygging.
AI-modellen kjørte på en dedikert server med GPU. Jeg brukte ikke AWS, Azure eller noen skyforvaltet løsning. Full kontroll over klientdata, null overføring av innhold til eksterne API-er.
Datamigrering fra MySQL til PostgreSQL
Migrering av databasen fra MySQL 5.5 til PostgreSQL krevde ikke bare overføring av data, men en grunnleggende ombygging av skjemaet. Det gamle skjemaet hadde ingen relasjoner, indekser eller integritetsbegrensninger.
-- 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();
SEO-migrering: omdirigeringer og strukturerte data
Det mest kritiske elementet i migreringen er å bevare plasseringene i Google. Til tross for at den gamle plattformen mistet trafikk, hadde den fortsatt hundrevis av indekserte URL-er og dusinvis av backlenker.
Jeg kartla hver gammel URL til den nye og implementerte 301-omdirigeringer på Cloudflare Workers-nivå:
// redirects.ts -- mapa przekierowań ze 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;
}
Jeg implementerte fullstendige Schema.org-strukturerte data med hreflang for hver språkversjon. Den gamle plattformen hadde ingen strukturerte data, og Google forsto ikke hva siden handlet om.
Resultater etter 4 måneder
| Metrikk | Før | Etter | Endring |
|---|---|---|---|
| PageSpeed (mobil) | 18 | 99 | +450% |
| LCP | 12,4s | 0,3s | -98% |
| INP | 1100ms | 22ms | -98% |
| CLS | 0,52 | 0,01 | -98% |
| TTFB | 4,7s | 0,03s | -99% |
| Sidestørrelse | 11,2 MB | 0,28 MB | -97% |
| API-svartid | 4700ms | 45ms | -99% |
| Søk (200k oppføringer) | 47s | 2ms | 23500x |
| Organisk trafikk | Grunnlinje | +340% | Vekst |
| Kundehenvendelser | ~2/uke | ~14/uke | +600% |
| Sikkerhetssårbarheter | 37 | 0 | Eliminert |
Fullstendig teknologistak for prosjektet:
- Backend-API: Python 3.12, Django 5, Django REST Framework, Celery
- Database: PostgreSQL 16 med GIN-indekser og fulltekstsøk
- Cache og køer: Redis 7 (cache, sesjoner, Celery-megler)
- Søkemikrotjeneste: Rust, Actix-web, Tantivy
- Interaktiv frontend: React 19, TypeScript, TanStack Query, Tailwind CSS
- Markedsføringsside: Astro 5 med React-øyer
- AI-pipeline: Python, tilpasset språkmodell på dedikert GPU
- Distribusjon: Cloudflare Pages + Workers, GitHub Actions CI/CD
- Overvåking: Sentry, Prometheus, Grafana
Hva dette prosjektet lærte meg
Ikke døm, diagnostiser. Kunden kom såret av en dårlig byråopplevelse. Det siste han trengte var enda en “ekspert” som fortalte ham hvor ille det var. I stedet presenterte jeg fakta i form av en rapport, forklarte risikoen og foreslo en handlingsplan med tydelig tidsplan.
Velg verktøy for oppgaven, ikke oppgaver for verktøyet. Django til API, React til interaktivt brukergrensesnitt, Rust til databehandling, Astro til statiske sider. Hver teknologi løser et konkret problem. Et monolittisk rammeverk for alt er en oppskrift på kompromisser.
AI er et verktøy, ikke magi. AI-pipelinen behandlet 1 247 sider på 4 timer, arbeid som manuelt ville tatt uker. Men hvert resultat krevde menneskelig verifisering. AI genererte forslag, mennesket tok beslutninger. En tilpasset modell på egen server gir full kontroll over klientdata.
Rust rettferdiggjør seg i konkrete tilfeller. Jeg skrev ikke hele plattformen i Rust. Jeg skrev en mikrotjeneste i Rust som behandler 200 000 oppføringer, og der er ytelsesforskjellen en størrelsesorden. Resten av systemet fungerer utmerket i Python og TypeScript.
SEO-migrering er ikke valgfritt. Det er obligatorisk. Uten URL-mapping og 301-omdirigeringer ville kunden ha mistet restene av organisk trafikk. Takket være korrekt migrering økte trafikken med 340% på 4 måneder.
Trenger du redning etter en dårlig byråopplevelse?
Hvis plattformen din ble bygget på utdaterte teknologier, er treg, usikker eller rett og slett ikke fungerer som den skal, kontakt WPPoland. Jeg gjennomfører en gratis innledende revisjon og presenterer en utbedringsplan med tydelig tidsplan og arbeidsomfang.
Hvert redningsprosjekt begynner med en samtale. Denne kunden ringte på en fredagskveld. Mandag morgen hadde han en rapport. Åtte uker senere hadde han en plattform han er stolt av.

