Cómo rescaté a una empresa europea estafada por una agencia. Migración de PHP 5.6 y jQuery a React, Django, PostgreSQL, Redis y Rust. Código, métricas y lecciones.
ES

Cómo rescaté una empresa estafada por una agencia: reconstrucción sobre arquitectura moderna

5.00 /5 - (31 votes )
Última verificación: 1 de mayo de 2026
19min de lectura
Caso de estudio
Desarrollador full-stack

Me llamó un viernes por la noche. La voz tranquila, pero en ella podía escuchar el cansancio de alguien que lleva semanas luchando con un problema sobre el que no tiene control. Dirige una empresa de servicios que atiende a clientes en cuatro países europeos. Pagó a una agencia por una plataforma web profesional. Recibió algo que parecía una plataforma profesional. Bajo la superficie se escondía una catástrofe.

Por un acuerdo de confidencialidad no puedo revelar el nombre de la empresa ni el sector. En cambio, puedo contar exactamente lo que encontré, lo que hice y por qué elegí unas tecnologías y no otras. Esta historia es un aviso para cualquiera que encargue la construcción de una plataforma a una agencia externa.


#Lo que encontré tras auditar la plataforma de la agencia

El primer paso es siempre la auditoría. No evalúo, no crítico: recojo hechos. Tras tres días de análisis tenía un panorama completo.

Código espagueti en PHP 5.6: la agencia no usó ningún framework. Toda la plataforma es código procedural monolítico en PHP 5.6 (fin de soporte en 2018) con consultas SQL pegadas directamente en las plantillas HTML. Sin ORM, sin capa de abstracción, sin enrutador. Archivos de 3000 líneas mezclando lógica de negocio con presentación.

// Código encontrado -- consulta SQL directamente en la plantilla (anonimizado)
<?php
$result = mysql_query("SELECT * FROM services 
    WHERE category = '" . $_GET['cat'] . "' 
    ORDER BY id DESC");
// SQL injection -- sin ninguna validación de datos de entrada
while ($row = mysql_fetch_assoc($result)) {
    echo "<div class='service'>";
    echo "<h2>" . $row['title'] . "</h2>"; // XSS -- sin escapado
    echo "<p>" . $row['description'] . "</p>";
    echo "</div>";
}
?>

MySQL 5.5 sin índices: base de datos con 47 tablas, ninguna con índices salvo las claves primarias. Una consulta que listaba servicios con filtros ejecutaba un full table scan sobre 200 000 registros, con un tiempo medio de respuesta de 4.7 segundos.

jQuery 1.x + Bootstrap 3: frontend del año 2014. Doce archivos jQuery cargados en cada página, incluyendo tres versiones distintas de la librería. Sin minificación, sin bundler, sin tree-shaking. Peso total de los scripts: 2.8 MB.

FTP como “despliegue”: sin repositorio Git, sin CI/CD, sin entorno de staging. La agencia subía los archivos directamente por FTP al servidor de producción. Sin sistema de control de versiones. Sin tests.

Cero seguridad: contraseñas de usuarios almacenadas en MD5 sin sal. Sesiones en archivos en un servidor compartido. SQL injection en 23 puntos. XSS en formularios. Sin tokens CSRF. Sin HTTPS en el panel de inicio de sesión.

#Métricas encontradas

MétricaValorEvaluación
PageSpeed (móvil)18Crítico
LCP12.4sCrítico
INP1100msCrítico
CLS0.52Crítico
TTFB4.7sCrítico
Peso de la página11.2 MBExcesivo
Tiempo de respuesta API4.7s (promedio)Crítico
Tráfico orgánicoCaída del 72% interanualCrítico
Vulnerabilidades detectadas23 SQL injection, 14 XSSCrítico

Lo peor era que el clientes no sabía nada de ninguno de estos problemas. La agencia le enviaba durante un año informes de “optimizaciones” que no tenían ningún reflejo en la realidad.


#Por qué elegí esta arquitectura tecnológica

La decisión sobre la arquitectura objetivo es el momento más importante del proyecto. El clientes tenía dudas razonables: la agencia anterior prometió “una solución moderna” y entregó código del año 2014. Tenía que elegir tecnologías que resolvieran problemas concretos, no las que estaban de moda.

Analicé los requisitos y ajusté las herramientas a las tareas:

Python + Django (API backend): el clientes necesitaba un backend sólido con panel de administración, autenticación, validación de datos y REST API. Django ofrece todo eso de serie. Django REST Framework es un ecosistema maduro y estable con documentación excelente. El clientes atiende a 4 mercados europeos, y Django tiene internacionalización integrada.

PostgreSQL (base de datos): migrar de MySQL 5.5 a PostgreSQL no es un capricho. PostgreSQL ofrece mejores índices (GIN, GiST para búsqueda de texto completo), mejores tipos de datos (JSONB, arrays), particionamiento de tablas maduro y transacciones ACID fiables. Para 200 000 registros con búsqueda de texto completo multilingüe es la elección natural.

Redis (caché y colas): el tiempo de respuesta de la API desde 4.7 segundos tenía que bajar por debajo de 100 milisegundos. Redis cachea los resultados de las consultas, almacena las sesiones de usuario y gestiona las colas de tareas asíncronas (Celery). Una herramienta, tres funciones críticas.

React + TypeScript (frontend interactivo): el dashboard del clientes, el buscador con filtros, los formularios multietapa, todo eso requiere una UI reactiva. React con TypeScript ofrece componentes tipados, excelentes herramientas de desarrollo y un ecosistema de librerías enorme.

Rust (microservicio de alto rendimiento): indexar la búsqueda de 200 000 registros en 4 idiomas, procesar archivos CSV/Excel de los clientes, transformar datos. Estas tareas requerían rendimiento puro. Rust procesa el índice de búsqueda en 1.8 segundos en lugar de los 47 segundos de la antigua implementación PHP. No es una diferencia porcentual, sino una diferencia de un orden de magnitud.

Astro (sitio de marketing): la página principal, el blog, la oferta, las páginas de servicios son contenidos estáticos que no necesitan JavaScript. Astro genera HTML limpio con coste de ejecución cero. Los elementos interactivos (buscador, formulario de contacto) funcionan como islas React aisladas. Como especialista en desarrollo Astro, este framework fue la eleccion natural para el sitio público.

Arquitectura objetivo:

┌─────────────────────────────────────────────────────┐
│                  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)    │ │
│  └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

#API backend en Django REST Framework

El núcleo de la nueva plataforma es Django con Django REST Framework. Construí una API que gestiona el catálogo multilingüe de servicios, el sistema de consultas de clientes, la autenticación JWT y el panel de administración.

# services/models.py -- modelo de servicio con multilingüismo
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 -- serializador con validación
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 -- vistas API con caché 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)

Configuración de Redis como backend de caché y broker de colas 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.clientes.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'

Impacto tras el despliegue de Django + PostgreSQL + Redis: el tiempo medio de respuesta de la API cayó de 4.7 segundos a 45 milisegundos. Las consultas servidas desde la caché de Redis se resuelven en 3-5 milisegundos.


#Microservicio Rust para indexación de búsqueda

El reto técnico más interesante fue el buscador. El clientes tiene un catálogo de 200 000 registros en 4 idiomas. La antigua implementación PHP ejecutaba LIKE '%term%' sobre MySQL sin índices: 47 segundos por consulta. Inutilizable.

PostgreSQL con índices GIN y tsvector resolvió el problema para las consultas estándar. Pero el clientes también necesitaba:

  • búsqueda con tolerancia a errores tipográficos (fuzzy matching),
  • filtrado por múltiples atributos a la vez con resultados instantáneos,
  • reconstrucción del índice tras la importación de datos desde archivos CSV/Excel.

Para estas tareas construí un microservicio en Rust con la librería Tantivy (equivalente a Apache Lucene para 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)
    }
}

API HTTP en Rust con el framework 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
}

Resultados del benchmark del microservicio Rust:

OperaciónPHP antiguoRust nuevoMejora
Construcción del índice (200k registros)47s1.8s26x más rápido
Búsqueda simple4.7s2ms2350x más rápido
Búsqueda con filtros8.3s5ms1660x más rápido
Fuzzy matching (errores tipográficos)No disponible8msNueva función
Uso de memoria512 MB84 MB6x menos

Rust no fue una elección “porque está de moda”. Fue una elección porque para esta tarea concreta, procesar 200 000 registros con indexación n-gram y fuzzy matching, ofrece un rendimiento inalcanzable para los intérpretes.


#Frontend React con dashboard interactivo

El dashboard del clientes requería una UI reactiva: tablas con ordenación, filtros multinivel, formularios multietapa, gráficos con datos en tiempo real. React con TypeScript es la elección natural.

// 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>
  );
}

El componente React se incrusta en la página Astro como una isla interactiva:

---
// 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 clientes:visible locale={Astro.params.lang} />
</Layout>

Gracias a la arquitectura Islands, el sitio de marketing carga 0 KB de JavaScript por defecto. El componente del buscador (38 KB gzipped) se carga solo cuando el usuario hace scroll hasta esa sección.


#Pipeline de IA para procesamiento de contenido

Migrar 1200 páginas de contenido desde HTML sucio a Markdown limpio requería automatización. Muchas páginas tenían metadatos SEO incompletos, descripciones ausentes, encabezados no optimizados. En lugar de corregirlos a mano, construí un pipeline en Python que aprovecha un modelo de lenguaje personalizado.

El pipeline funcionaba en tres fases:

# 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 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

El pipeline procesó 1247 páginas en 4 horas sobre infraestructura propia, no en la nube ni a través de ninguna API externa. Encontró 3400 entidades únicas, generó las meta descripciones faltantes para 680 páginas e identificó 89 páginas con thin content que necesitaban ampliación.

El modelo de IA funcionaba en un servidor dedicado con GPU, sin usar AWS, Azure ni ninguna solución cloud gestionada. Control total sobre los datos del clientes, cero transmisión de contenido a APIs externas.


#Migración de datos de MySQL a PostgreSQL

La migración de la base de datos de MySQL 5.5 a PostgreSQL requería no solo trasladar los datos, sino una reconstrucción fundamental del esquema. El esquema antiguo no tenía relaciones, índices ni restricciones de integridad.

-- 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();

#Migración SEO: redirecciones y datos estructurados

El elemento más crítico de la migración es preservar las posiciones en Google. A pesar de que la plataforma antigua estaba perdiendo tráfico, seguía teniendo cientos de URL indexadas y decenas de backlinks.

Mapeé cada URL antigua a la nueva y desplegué redirecciones 301 a nivel de Cloudflare Workers:

// 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;
}

Implementé datos estructurados Schema.org completos con hreflang para cada versión lingüística. La plataforma antigua no tenía ningún dato estructurado, por lo que Google no entendía de qué trataba la página.


#Resultados a los 4 meses

MétricaAntesDespuésCambio
PageSpeed (móvil)1899+450%
LCP12.4s0.3s-98%
INP1100ms22ms-98%
CLS0.520.01-98%
TTFB4.7s0.03s-99%
Peso de la página11.2 MB0.28 MB-97%
Tiempo de respuesta API4700ms45ms-99%
Búsqueda (200k registros)47s2ms23500x
Tráfico orgánicoBase+340%Crecimiento
Consultas de clientes~2/semana~14/semana+600%
Vulnerabilidades de seguridad370Eliminadas

Arquitectura tecnológica completa del proyecto:

  • API backend: Python 3.12, Django 5, Django REST Framework, Celery
  • Base de datos: PostgreSQL 16 con índices GIN y búsqueda de texto completo
  • Caché y colas: Redis 7 (caché, sesiones, broker Celery)
  • Microservicio de búsqueda: Rust, Actix-web, Tantivy
  • Frontend interactivo: React 19, TypeScript, TanStack Query, Tailwind CSS
  • Sitio de marketing: Astro 5 con islas React
  • Pipeline de IA: Python, modelo de lenguaje personalizado en GPU dedicada
  • Despliegue: Cloudflare Pages + Workers, GitHub Actions CI/CD
  • Monitoreo: Sentry, Prometheus, Grafana

#Lo que me enseñó este proyecto

No juzgues, diagnostica. El clientes llegó herido por una mala experiencia con una agencia. Lo último que necesitaba era otro “experto” diciéndole lo mal que estaban las cosas. En su lugar, presenté los hechos en forma de informe, expliqué los riesgos y propuse un plan de acción con un calendario claro.

Elige la herramienta para la tarea, no la tarea para la herramienta. Django para la API, React para la UI interactiva, Rust para el procesamiento de datos, Astro para las páginas estáticas. Cada tecnología resuelve un problema concreto. Un framework monolítico para todo es una receta para compromisos continuos.

La IA es una herramienta, no magia. El pipeline de IA procesó 1247 páginas en 4 horas, un trabajo que a mano habría llevado semanas. Pero cada resultado requería verificación humana. La IA generaba sugerencias, la persona tomaba decisiones. Un modelo personalizado en servidor propio da control total sobre los datos del clientes.

Rust se justifica en casos concretos. No escribí toda la plataforma en Rust. Escribí en Rust un único microservicio que procesa 200 000 registros, y ahí la diferencia de rendimiento es de un orden de magnitud. El resto del sistema funciona perfectamente en Python y TypeScript.

La migración SEO no es opcional, es obligatoria. Sin mapeo de URL y redirecciones 301, el clientes habría perdido los últimos restos de tráfico orgánico. Gracias a la migración correcta, el tráfico creció un 340% en 4 meses.


#¿Necesitas un rescate tras una mala experiencia con una agencia?

Si tu plataforma fue construida con tecnologías obsoletas, es lenta, insegura o simplemente no funciona como debería, contacta con WPPoland. Realizaré una auditoría inicial gratuita y presentaré un plan de acción con un calendario claro y un alcance de trabajo definido.

Cada proyecto de rescate empieza con una llamada. Este clientes llamó un viernes por la noche. El lunes por la mañana tenía el informe. Ocho semanas después tenía una plataforma de la que está orgulloso.

Siguiente paso

Transforma el artículo en una implementación real

Este bloque refuerza el enlazado interno y lleva al lector al siguiente paso más útil dentro de la arquitectura del sitio.

Cluster relacionado

Explora otros servicios WordPress y base de conocimiento

Refuerza tu negocio con soporte técnico profesional en áreas clave del ecosistema WordPress.

¿Cuánto tiempo lleva migrar una plataforma de una arquitectura obsoleta a una moderna?
El tiempo depende de la escala del proyecto y se determina de forma individual tras la auditoría. Una plataforma sencilla lleva entre 4 y 6 semanas. Un sistema complejo con múltiples integraciones, multilingüismo y componentes interactivos requiere entre 8 y 14 semanas.
¿Por qué Rust en lugar de Node.js o Go para microservicios?
Rust ofrece un rendimiento comparable al de C/C++ con garantías de seguridad de memoria. Para tareas que requieren procesar grandes conjuntos de datos, indexación de búsqueda y transformaciones de archivos, Rust ofrece una ventaja de rendimiento de un orden de magnitud sobre Node.js.
¿Afecta la migración a una nueva arquitectura al posicionamiento en Google?
Una migración correctamente realizada con mapeo de URL y redirecciones 301 protege las posiciones existentes. La mejora de los Core Web Vitals tras la migración suele provocar un aumento del tráfico orgánico de entre el 30 y el 50 por ciento en 3 meses.
¿Qué hacer cuando una agencia te ha estafado y ha dejado la plataforma sin documentación?
El primer paso es la auditoría inicial: revisar el código, las licencias, la seguridad y el rendimiento. Después, recuperar los datos y el contenido de la instalación existente. Solo entonces planificar la migración a una nueva plataforma con documentación completa.

¿Necesitas un FAQ adaptado a tu sector y mercado? Preparamos una versión alineada con tus objetivos de negocio.

Hablemos

Artículos Relacionados

Astro 5 o Next.js 15: cual framework elegir en 2026? Comparación en profundidad de rendimiento, arquitectura, casos de uso y cuando usar cada uno para proyectos WordPress Headless.
wordpress

Astro 5 vs Next.js 15: Comparación técnica completa 2026

Astro 5 o Next.js 15: cual framework elegir en 2026? Comparación en profundidad de rendimiento, arquitectura, casos de uso y cuando usar cada uno para proyectos WordPress Headless.

Descubre cuándo una reconstrucción de sitio web es necesaria. 7 señales técnicas y de negocio medibles que indican que tu sitio necesita modernización en 2026.
wordpress

¿Cuándo reconstruir tu sitio web? 7 señales de que necesita modernización

Descubre cuándo una reconstrucción de sitio web es necesaria. 7 señales técnicas y de negocio medibles que indican que tu sitio necesita modernización en 2026.

WordPress 7.0 con AI Client vs Astro 6 tras la adquisición de Cloudflare. Comparativa de velocidad, coste, SEO y seguridad. Mi opinión tras 20 años como desarrollador WP - cuándo migrar y cuándo quedarse.
wordpress

WordPress 7.0 vs Astro 6 tras la adquisición de Cloudflare - ¿quién gana en 2026?

WordPress 7.0 con AI Client vs Astro 6 tras la adquisición de Cloudflare. Comparativa de velocidad, coste, SEO y seguridad. Mi opinión tras 20 años como desarrollador WP - cuándo migrar y cuándo quedarse.