Migrando un Sitio E-Commerce a Astro: Cómo Aprendí a Amar la Arquitectura Headless
El año pasado asumí lo que parecía ser un proyecto simple. Convertir un sitio web WordPress de productos para el cuidado de la piel a Astro. La cliente, una empresaria harta de su sitio web lento, vende cosméticos naturales en Chile; nada elegante, solo productos de calidad.
Estimé que podría tomar tres semanas. Cuatro como máximo.
Pasaron dos meses. Sin embargo, esos dos meses me enseñaron más que el año que pasé trabajando como freelancer.
El Problema de WordPress (Que No Tenía Nada que Ver con WordPress)
Lo que pasa con WooCommerce y WordPress es que son funcionales. Realmente funcionan. Este stack impulsa millones de negocios en línea que generan ingresos todos los días. Por lo tanto, el problema no era que estuviera dañado.
El problema era su lentitud. Y lo difícil de editar. Además, la cliente continuaba enviándome capturas de pantalla de PageSpeed con el lamentable puntaje naranja de 45.
Cada. maldita. Semana. “Jaime, ¿esto es normal?”
No, no es normal. No podía decirle que en realidad es bastante común para sitios WordPress que no han sido optimizados.
Sin embargo, la velocidad no era la verdadera agonía. Era observarla luchar con el admin de WordPress. Ella es una empresaria que crea productos increíbles para el cuidado de la piel; no es técnica. ¿Por qué es necesario que comprenda el editor de bloques de WordPress para modificar un título?
Una vez la vi. Por Zoom. Quería actualizar el banner de la página principal para una venta. Necesitó quince minutos. Quince minutos para navegar por los bloques, encontrar la página correcta, hacer clic en los menús, hacer el cambio, vista previa (que rompió el diseño), arreglar el diseño, vista previa otra vez, y luego publicar.
Una vez borró accidentalmente una sección completa de productos. Simplemente… desapareció. Porque accidentalmente presionó el botón X del editor.
Me di cuenta entonces de que necesitábamos un enfoque diferente.
La Estrategia (que Modifiqué Tres Veces)
Inicialmente pensé, “Okay, simplemente optimicemos el tema.” Limpiar algo de código, agregar caché, cargar imágenes de forma diferida, y considerar usar un CDN. Técnicas típicas de optimización de rendimiento.
Pero entonces comencé a preguntarme: ¿qué tal si no lo hacemos?
¿Qué tal si utilizamos WordPress para sus verdaderas fortalezas en lugar de intentar hacerlo rápido? WooCommerce es excelente para rastrear pedidos, gestionar inventario y procesar pagos. Eso equivale a quince años de lógica e-commerce probada. ¿Por qué reconstruiría eso?
Sin embargo, ¿el front end? ¿Lo que ven los usuarios? Eso podría ser algo completamente diferente.
Astro entró en escena en ese momento. Había escuchado sobre este enfoque de “hidratación parcial”, donde JavaScript solo se envía para las secciones que realmente lo requieren. De todos modos, el contenido estático constituye la mayoría de un sitio web e-commerce. La página about, entradas de blog, y listados de productos. Para una página que esencialmente consiste en texto e imágenes, ¿por qué estamos enviando 200KB de JavaScript?
Así, la estrategia se convirtió en:
- Mantener WooCommerce para inventario y pagos.
- Usar Astro para un frontend rápido.
- Incluir TinaCMS para que la cliente pueda hacer ediciones visuales.
Para ser honesto, TinaCMS era un poco arriesgado. Nunca lo había aplicado a un proyecto real. Sin embargo, la demo de edición visual parecía ser exactamente lo que la cliente necesitaba.
Una Línea de Tiempo de Cuando Todo Se Rompió
Semana 1: La Aventura con la API de WooCommerce
En teoría, parecía fácil importar datos de productos desde WooCommerce a Astro. Hay una API REST para WooCommerce. Astro puede recuperar datos. Debería estar bien, ¿verdad?
Voz del narrador: No estuvo bien.
Sí, la API está documentada. Sin embargo, hay dos tipos: las que están documentadas y las que “realmente funcionan de la manera que esperarías.” Estas no son intercambiables.
Para recuperar productos de manera confiable, pasé dos días creando un wrapper de TypeScript. El código en sí es directo: formatear el precio en pesos chilenos y recuperar productos por ID. ¿Pero hacer que funcione consistentemente? Requirió algo de tiempo.
// Fetch products by ID with type safety
const products = await getProductsByIds([123, 456, 789]);
// Format prices in local currency
formatPrice(21990); // Returns "$21.990"
// Generate add-to-cart URLs
getAddToCartUrl(productId);Gestionar fallos fue el verdadero desafío. Porque los usuarios ven páginas de productos en blanco cuando la API de un sitio e-commerce se cae y no estás preparado. Por lo tanto, no hay ventas. Esto indica que la cliente te llama en pánico.
Por lo tanto, creé un plan de respaldo. El sitio web muestra datos de productos en caché en caso de que la API falle por cualquier razón, como un problema de red, credenciales vencidas, o WooCommerce decidiendo tomar una siesta. Aunque los precios estén un poco desactualizados, sigue siendo mucho mejor que una página en blanco.
export async function getProductsByIds(ids: number[]) {
try {
const response = await fetch(`${WC_URL}/wp-json/wc/v3/products`, {
headers: {
'Authorization': `Basic ${base64Credentials}`
},
params: { include: ids.join(',') }
});
return await response.json();
} catch (error) {
console.error('WooCommerce API error:', error);
// Return cached/fallback product data
return getFallbackProducts(ids);
}
}¿Es perfecto? No. ¿Detiene las llamadas de pánico a las tres de la mañana? De hecho.
Semana 3: La Historia del Carrito de Compras
El carrito fue… intrigante.
Quería que tuviera una sensación contemporánea. ¿Conoces esos hermosos sitios web e-commerce donde agregar artículos a tu carrito es un proceso sin interrupciones? Solo un hermoso drawer que muestra tus artículos, sin comportamientos raros o recargas de página. Quería eso.
Sin embargo, todavía se requería el checkout de WooCommerce. Porque pagos, una vez más. No voy a reconstruir un sistema de pagos.
Mi solución—un drawer de carrito React que almacena todo en localStorage mientras navegas y luego codifica todos los datos del carrito en la URL y te envía a WordPress—probablemente no fue lo más elegante que he creado, pero funciona.
Sí. Especificaciones de URL. Para datos del carrito.
Los desarrolladores ya están gritando. “¡Las URLs no deberían ser tan largas!” Escucha, funciona correctamente para el 95% de los pedidos (1-3 artículos). Podríamos tener problemas con el cliente infrecuente que compra veinte artículos a la vez. Sin embargo, eso aún no ha ocurrido.
https://example.com/?cart_data=PRODUCT_ID:QTY,PRODUCT_ID:QTY&auth_token=SIGNED_TOKENCreé un plugin para WordPress que toma esos parámetros de URL, analiza el contenido del carrito, los agrega al carrito de WooCommerce, y luego redirige a la página de checkout.
<?php
// WordPress plugin: Cart transfer handler
add_action('init', function() {
if (isset($_GET['cart_data'])) {
$items = explode(',', sanitize_text_field($_GET['cart_data']));
// Clear existing cart
WC()->cart->empty_cart();
// Add all items
foreach ($items as $item) {
list($product_id, $quantity) = explode(':', $item);
WC()->cart->add_to_cart(
intval($product_id),
intval($quantity)
);
}
// Redirect to checkout
wp_safe_redirect(wc_get_checkout_url());
exit;
}
});El usuario ve que su carrito ya ha sido llenado. Encantamiento. (La cliente no necesita saber que son parámetros de URL hacky; no es magia.)
Semana 4: La Catástrofe de SSO
No debería ser necesario que los usuarios inicien sesión dos veces. Eso es simplemente mala UX. Cuando un usuario se registra en el frontend de Astro, deberían iniciar sesión automáticamente cuando lleguen al checkout de WooCommerce.
La respuesta obvia parecía ser tokens JWT. Es simple: crear un token firmado en Astro, enviarlo a WordPress, verificarlo, e iniciar sesión.
Excepto por una tarde de jueves cuando nada funcionó durante dos horas.
La validación del token continuaba fallando. Revisé todo. Sí, el algoritmo de firma es SHA256. La estructura del payload está bien, incluyendo las marcas de tiempo y el ID de usuario. Espera, la clave secreta.
Había un símbolo de hash en la clave secreta. Además, todo lo que venía después del hash estaba siendo tratado como un comentario en mi archivo .env.
# Wrong - everything after # is ignored
JWT_SECRET=abc123#def456
# Correct - quoted to preserve special chars
JWT_SECRET="abc123#def456"Por lo tanto, solo se estaba usando la mitad del secreto. La clave incorrecta se estaba usando para firmar los tokens. La validación falló, por supuesto.
La depuración tomó dos horas completas. Porque no encerré el secreto entre comillas.
Esto todavía me irrita.
La generación de tokens en el lado de Astro:
import { createHmac } from 'crypto';
const generateSSOToken = (userId: string, secret: string): string => {
const payload = {
sub: userId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
};
const signature = createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return `${Buffer.from(JSON.stringify(payload)).toString('base64')}.${signature}`;
};WordPress valida el token y autentica al usuario:
<?php
// WordPress plugin: SSO token validation
function validate_sso_token($token) {
list($payload_encoded, $signature) = explode('.', $token);
$payload = json_decode(base64_decode($payload_encoded), true);
// Verify signature
$expected_signature = hash_hmac(
'sha256',
json_encode($payload),
JWT_SECRET
);
if (!hash_equals($expected_signature, $signature)) {
return false;
}
// Check expiration
if ($payload['exp'] < time()) {
return false;
}
// Log user in
$user = get_user_by('id', $payload['sub']);
wp_set_auth_cookie($user->ID);
return true;
}Semanas 5–6: Curva de Aprendizaje para TinaCMS
TinaCMS cumple su promesa de edición visual. Sin embargo, no había una manera obvia de llegar allí.
Los componentes de Astro son estáticos, ese es el problema. Solo se renderizan una vez durante el proceso de construcción. Para que la edición en vivo funcione, TinaCMS requiere componentes React. Por lo tanto, tuve que usar su hook useTina para envolver cada sección editable en un componente React.
// tina/pages/SectionWrapper.tsx
import { useTina, tinaField } from 'tinacms/dist/react';
export default function SectionWrapper({ query, variables, data }) {
const { data: tinaData } = useTina({ query, variables, data });
const section = tinaData?.section;
return (
<section data-tina-field={tinaField(section, 'title')}>
<h2>{section.title}</h2>
<div data-tina-field={tinaField(section, 'description')}>
{section.description}
</div>
</section>
);
}Luego en la página de Astro:
---
import SectionWrapper from '@/tina/pages/SectionWrapper';
import { client } from '@/tina/__generated__/client';
const { data, query, variables } = await client.queries.section({
relativePath: 'section.json'
});
---
<SectionWrapper
client:load
{query}
{variables}
{data}
/>Una vez que comprendes el patrón, no es difícil. Sin embargo, me tomó más tiempo del que me gustaría reconocer descubrir el patrón. Aunque todavía hay una curva de aprendizaje, los documentos son buenos.
¿Pero ahora? La cliente lo adora. Tiene la capacidad de hacer clic en cualquier texto, editarlo directamente en la página, ver sus cambios instantáneamente, y luego presionar guardar. El admin de WordPress ya no es necesario. No más borrado accidental de partes.
La curva de aprendizaje vale la pena.
Soporte Bilingüe (La Manera Fácil)
Se requerían versiones en español e inglés para el sitio web. Examiné bibliotecas i18n. Todas parecían excesivas para esencialmente “mostrar contenido diferente según la URL.”
Decidí mantener las cosas simples creando carpetas distintas para el contenido de cada idioma. Una carpeta contiene entradas de blog en español, mientras que otra contiene las de inglés. Se envían archivos JSON separados a las páginas.
content/
├── blog/ # Spanish posts
├── blog-en/ # English posts
└── pages/
├── home.json
└── home-en.jsonEl prefijo /en/ se aplica a las rutas en inglés. Eso es todo.
// src/pages/blog/[slug].astro (Spanish)
// src/pages/en/blog/[slug].astro (English)
const { slug } = Astro.params;
const locale = Astro.url.pathname.startsWith('/en') ? 'en' : 'es';
const posts = await getCollection(locale === 'en' ? 'blog-en' : 'blog');¿Es esta la estrategia de internacionalización usada por grandes corporaciones? Probablemente no. ¿Funciona perfectamente para este proyecto? Por supuesto.
La solución sencilla es a veces la mejor.
Haciéndolo Rápido (La Parte Divertida)
¿Recuerdas ese puntaje de PageSpeed de 45? Llegamos a 92.
Esto es lo que genuinamente cambió las cosas:
Fuentes Auto-Hospedadas
Google Fonts puede ser reemplazado con auto-hospedaje. Esta fue la mayor victoria. El renderizado es bloqueado por una solicitud externa hecha por Google Fonts. First Contentful Paint se redujo en 1.4 segundos al cambiar a Fontsource, que usa las mismas fuentes pero sirve archivos WOFF2 desde mi propio dominio. Una modificación, efecto enorme.
// astro.config.mjs
import fontsource from '@fontsource/source-sans-pro';
export default defineConfig({
// Fonts bundled at build time, no external requests
});Video con Carga Diferida
Este hermoso video de fondo está en la página principal. 4.3MB al principio. Lo convertí a WebM (1.3MB) y solo lo cargué cuando el viewport estaba abierto. ¿Por qué forzarlos a descargarlo cuando la mayoría de usuarios móviles nunca siquiera se desplazan lo suficiente para verlo?
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const video = entry.target as HTMLVideoElement;
video.src = video.dataset.src!;
video.load();
observer.unobserve(video);
}
});
});
observer.observe(videoElement);Imágenes Responsivas
Tres tamaños de imágenes responsivas. Las imágenes en dispositivos móviles son de 300 píxeles de ancho. Las tabletas reciben 600 píxeles. El escritorio recibe 1200 píxeles. No revolucionario, pero muy efectivo.
<img
src={image.url}
srcset={`
${image.url}?w=300 300w,
${image.url}?w=600 600w,
${image.url}?w=1200 1200w
`}
sizes="(max-width: 768px) 300px, (max-width: 1200px) 600px, 1200px"
alt={image.alt}
/>Precarga Estratégica
<link rel="preload" as="image" href={heroImage} fetchpriority="high" />La división de código fue la optimización que falló. Antes de darme cuenta de que la mayoría de las páginas de Astro no envían JavaScript en absoluto, intenté optimizar los tamaños de los bundles de JavaScript durante medio día. Estaba haciendo el tipo incorrecto de optimización.
SEO Sin Necesidad de Ruedas de Entrenamiento
Yoast se perdió durante la migración de WordPress. Esto resultó en la pérdida de la generación automática de schema. Lo cual requirió hacer todo a mano.
Datos estructurados JSON-LD para entradas de blog, información de negocio local, y detalles de organización. Es tedioso pero esencial. Estás perdiendo puntos SEO si no le das a Google esta información.
---
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
"name": "Brand Name",
"url": Astro.site,
"logo": `${Astro.site}logo.png`,
"sameAs": [
"https://facebook.com/page",
"https://instagram.com/account"
]
};
const localBusinessSchema = {
"@context": "https://schema.org",
"@type": "BeautySalon",
"name": "Business Name",
"address": {
"@type": "PostalAddress",
"streetAddress": "Street Address",
"addressLocality": "City",
"addressCountry": "CL"
},
"openingHours": "Mo-Fr 09:00-18:00"
};
---
<script type="application/ld+json" set:html={JSON.stringify(organizationSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(localBusinessSchema)} />Para posts de blog:
---
const blogPostingSchema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": post.title,
"datePublished": post.date,
"author": {
"@type": "Person",
"name": post.author
},
"image": post.image
};
---Porque Astro tiene un plugin que gestiona el sitemap automáticamente, incluyendo etiquetas hreflang para la configuración bilingüe, fue más simple.
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [
sitemap({
i18n: {
defaultLocale: 'es',
locales: {
es: 'es',
en: 'en'
}
}
})
]
});Seguridad (Las Cosas de las que Nadie Habla)
Limitar la tasa de intentos de inicio de sesión para detener ataques de fuerza bruta es parte de la seguridad (las cosas de las que nadie habla). Backoff exponencial por un máximo de treinta segundos.
const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
async function handleLogin(email: string, password: string) {
const attempts = loginAttempts.get(email) || { count: 0, lastAttempt: 0 };
// Exponential backoff: 2^attempts seconds
const delay = Math.min(Math.pow(2, attempts.count) * 1000, 30000);
const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;
if (timeSinceLastAttempt < delay) {
throw new Error(`Too many attempts. Wait ${Math.ceil((delay - timeSinceLastAttempt) / 1000)}s`);
}
// Attempt login...
attempts.count++;
attempts.lastAttempt = Date.now();
loginAttempts.set(email, attempts);
}Para tokens de autenticación, usa cookies HttpOnly para prevenir que JavaScript acceda a ellos. Para detener CSRF, SameSite=Strict.
export function setAuthCookie(token: string) {
return new Response(null, {
headers: {
'Set-Cookie': `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
}
});
}Validación de variables de entorno durante el inicio. La aplicación no se iniciará si las variables importantes no están presentes. No hay plan de respaldo “probablemente estará bien”.
const requiredEnvVars = ['JWT_SECRET', 'WC_CONSUMER_KEY', 'WC_CONSUMER_SECRET'];
requiredEnvVars.forEach(varName => {
if (!import.meta.env[varName]) {
throw new Error(`Missing required environment variable: ${varName}`);
}
});Porque no es agradable tener incidentes de producción a las dos de la mañana.
Despliegue (La Parte Simple)
El despliegue es casi aburrido con Vercel. Se despliega automáticamente cuando haces push a main. Los cambios en el contenido son confirmados a Git por TinaCMS, lo que luego inicia una reconstrucción y despliegue.
La cliente no está al tanto de que algo de esto está ocurriendo. Después de hacer cambios en el contenido y hacer clic en “Guardar,” sale en vivo en cuestión de minutos. Así es como deberían ser las cosas.
Para mantener el SEO, configuré redirecciones 301 para todas las URLs antiguas de WordPress. Los posts de blog tuvieron que ser redirigidos, y las URLs de productos tuvieron que cambiar su estructura.
{
"redirects": [
{
"source": "/product/:slug",
"destination": "/producto/:slug",
"permanent": true
},
{
"source": "/:slug",
"destination": "/blog/:slug",
"permanent": true
}
]
}Lo Que Haría Diferente
Hacer un plan más temprano para el mecanismo del carrito. Tuve que refactorizarlo dos veces porque no me di cuenta de lo complicado que sería.
Reservar más tiempo para familiarizarme con TinaCMS. La configuración tomó más tiempo de lo anticipado, pero una vez que funciona, la edición visual es fantástica.
Probar variables de entorno con caracteres especiales de inmediato. Era posible evitar ese bug del símbolo de hash.
Los Resultados
En móvil, PageSpeed aumentó de 45 a 92.
Sin contactarme, la cliente es capaz de editar contenido por su cuenta.
Los pagos todavía son manejados de manera confiable por WooCommerce.
Aprendí una gran cantidad de conocimiento sobre arquitectura headless.
¿Repetiría la acción? Sí, probablemente. Sin embargo, aumentaría el precio.
Observaciones Finales
Headless no se trata de utilizar la tecnología más nueva y avanzada. Usar la herramienta apropiada para cada tarea es crucial.
WooCommerce es una excelente plataforma e-commerce. Consérvala.
El admin de WordPress es engorroso. Cámbialo.
Los temas de WordPress son lentos. Cámbialos.
No tienes que empezar desde cero. Todo lo que tienes que hacer es descubrir qué no está funcionando y reemplazarlo.
Comienza modestamente. Inicia Astro. Incluye TinaCMS en una sola sección. Determina cómo integrar WooCommerce. Construye gradualmente.
Además, si tus variables de entorno contienen caracteres especiales, por favor enciérralas entre comillas por la cordura de todos.
Ten fe en mí.