Tenía mi portfolio en React y mi blog de WordPress en la raíz del dominio. Lo reconstruí entero: portfolio en PHP puro, sin framework ni bundler, y WordPress movido a un subdirectorio con Nginx, sin Apache ni .htaccess. Este artículo es el registro de qué decidí, por qué, y los problemas reales que aparecieron al montarlo.
No es teoría. Es lo que rompió, lo que me costó entender y cómo lo dejé funcionando. Si vas a poner WordPress en un subdirectorio con Nginx, te ahorro varias horas.
Por qué dejé React y volví a PHP puro
Mi portfolio es una página. Texto, mis proyectos, enlaces, dos idiomas. Para eso tenía un stack de React con su bundler, su build, sus dependencias que envejecen. Cada vez que quería tocar una línea de texto, arrancaba un proceso de build. Para una página estática con i18n, eso es traer una excavadora a plantar un geranio.
Así que lo tiré y lo reescribí en PHP plano. Sin framework, sin bundler, sin un solo kilobyte de JavaScript para renderizar la página. El idioma se resuelve en el servidor: detecto por query param ?lang=, si no hay miro la cookie, y si no caigo a inglés por defecto. El contenido vive en dos archivos, lang/en.php y lang/es.php, y el index.php los carga según toque.
De paso moví WordPress de la raíz a una carpeta /blog/. Quería que el portfolio fuera la cara del dominio y el blog colgara de una URL limpia y natural. Esa decisión, que parecía trivial, fue la que más problemas de Nginx me trajo.
La arquitectura de un vistazo
El mapa es sencillo. En la raíz, index.php: el portfolio, con i18n server-side y cero JavaScript. En /blog/, una instalación de WordPress con un tema propio que llamé «felipe» y el plugin Polylang para los dos idiomas.
El portfolio y el blog comparten apariencia pero no código. El footer con los enlaces sociales está en los dos —en PHP en el portfolio, en el tema en WordPress— con iconos SVG inline, sin librería externa. Los lang files son la fuente única del contenido del portfolio: ahí está todo el texto en los dos idiomas, y también las meta descriptions que uso para el SEO.
Dos mundos, entonces: PHP plano que controlo línea a línea, y WordPress para escribir y publicar cómodo. La frontera entre ambos es la carpeta /blog/, y ahí es donde Nginx tuvo que aprender a repartir el tráfico.
Nginx sin .htaccess: los problemas reales
Aquí está la carne. Si vienes de Apache, estás acostumbrado a que un .htaccess resuelva los rewrites por ti. En Nginx no existe eso: todo se declara en la configuración del servidor, y WordPress en un subdirectorio no funciona hasta que se lo dices con precisión.
El primer golpe fueron las URLs de Polylang. El español queda en /blog/ y el inglés en /blog/en/, pero al entrar en /blog/en/ Nginx devolvía un 404. El problema: no había una regla que mandara esas rutas al index.php de WordPress. Lo arregla un location /blog/ con try_files que, si el fichero o el directorio no existen, reescribe hacia el index.php del subdirectorio.
location /blog/ {
try_files $uri $uri/ /blog/index.php?$args;
}
Esa línea es la que hace que WordPress en subdirectorio con Nginx funcione de verdad. Sin ella, cualquier permalink que no apunte a un archivo físico se cae.
El segundo punto fue la base de datos. Al mover WordPress de la raíz a /blog/, las opciones siteurl y home seguían apuntando a la raíz, así que WordPress se peleaba consigo mismo. Las actualicé directamente en la base de datos a la nueva ruta y todo se reubicó.
Después, dos reglas de limpieza. Bloqueé xmlrpc.php, que en una instalación pequeña no me aporta nada y solo es superficie de ataque para fuerza bruta. Y añadí una regla para servir un sitemap.php dinámico bajo la URL /sitemap.xml, porque quería generar el sitemap con código (luego cuento por qué).
Sobre Polylang: lo configuré con hide_default: true y default_lang: es. Así el idioma por defecto, el español, no lleva prefijo y se queda en /blog/, mientras que el inglés vive en /blog/en/. URLs limpias en el idioma principal, sin un /es/ redundante colgando de todo.
El tema mínimo y el SEO técnico a mano
El tema de WordPress es deliberadamente mínimo. Tiene solo dos plantillas: archive.php para el listado y single.php para el post. No hay portada ni intento de replicar el portfolio dentro de WordPress; la cara del sitio es el index.php de la raíz, y el blog es el blog. El i18n del tema lo resuelvo con pll_current_language() de Polylang: los textos del archive, del single y del footer salen según el idioma activo. Las imágenes de los posts las fuerzo a cuadrado (1:1), pero solo en el archive, para que el listado quede regular.
El SEO técnico lo hice a mano, sin plugin. La meta description es dinámica por idioma, leyéndola de los lang files. El og:locale cambia según el idioma (es_ES o en_US), la og:image y la twitter:card van como summary_large_image, y monté el set completo de favicons: el .ico clásico, la versión SVG y el apple-touch-icon.
La pieza de la que estoy más contento es el sitemap. En vez de un XML estático o un plugin, escribí un sitemap.php que carga WordPress y genera el sitemap.xml al vuelo. Incluye el portfolio con sus hreflang EN/ES, las páginas del blog y todos los posts con sus traducciones de Polylang y la entrada x-default. Un sitemap dinámico en PHP con hreflang que se mantiene solo: cuando publico un post, ya está en el sitemap con sus dos idiomas, sin que yo toque nada.
Y un detalle de privacidad del que pocos se acuerdan: mi número de WhatsApp no aparece nunca en el HTML. El enlace apunta a un wa.php que hace un redirect 302 en el servidor hacia WhatsApp. Así el número no viaja al navegador y no lo pescan los scrapers que rastrean webs buscando teléfonos.
Diseño e imágenes con Claude Design
Usé Claude Design en dos fases distintas. La primera, para la dirección de diseño: el arranque visual del tema y las guías de estilo. De ahí exporté HTML, y sobre ese HTML ajusté las particularidades y metí los textos reales. Es decir, no partí de una página en blanco peleándome con el CSS; partí de una base visual coherente y la afiné a mano.
La segunda fase, ya con el diseño decidido, fue de assets. Generé los iconos del sitio y los de WordPress, y las imágenes de las secciones del portfolio y de cada artículo. Tener una sola herramienta para la dirección de diseño y para la imagería mantuvo todo coherente: los iconos, las imágenes de los posts y el aire general del sitio hablan el mismo idioma visual sin que yo tuviera que ser diseñador.
Del local al servidor: FTP sobre LEMP
El servidor es una VPS propia que autogestiono, con stack LEMP: Linux, Nginx, MySQL y PHP 8.3-FPM. Sin Apache en ningún punto.
El despliegue es deliberadamente poco sofisticado: subo los archivos por FTP, el mismo flujo para el portfolio PHP plano y para WordPress. No hay pipeline de CI, ni build que ejecutar, ni paso de compilación. Y esa es justo una de las ventajas de haber vuelto a PHP puro: lo que edito es lo que se sirve. Subo el archivo y está en producción. Para un sitio de este tamaño, montar un pipeline elaborado sería resolver un problema que no tengo.
Qué haría igual y qué cambiaría
Lo de volver a PHP puro para el portfolio lo repetiría sin dudarlo. Para una página con i18n y sin interactividad, React era peso muerto. Editar un texto y verlo en producción sin un build de por medio es exactamente la fricción que quería quitarme.
Lo del sitemap dinámico en PHP también: cero mantenimiento y siempre correcto. Y los problemas de Nginx, una vez entendidos, son configuración que escribes una vez y olvidas.
¿Qué cambiaría? Probablemente el despliegue por FTP. Funciona y para este tamaño no me duele, pero un git pull por SSH sería más limpio y menos propenso a subir un archivo a medias. No es urgente, pero es la pieza más artesanal del montaje y la primera que tocaría si el sitio creciera.
Si te llevas una sola cosa de aquí, que sea el location /blog/ con try_files. Es la línea que separa «WordPress no carga en el subdirectorio y no sé por qué» de que todo funcione. El resto es trabajo; eso es el truco.