{"id":239636,"date":"2026-05-29T21:36:00","date_gmt":"2026-05-29T21:36:00","guid":{"rendered":"https:\/\/felipenespralsanchez.tech\/blog\/?p=239636"},"modified":"2026-06-13T21:41:26","modified_gmt":"2026-06-13T21:41:26","slug":"wordpress-subdirectorio-nginx-portfolio-php","status":"publish","type":"post","link":"https:\/\/felipenespralsanchez.tech\/blog\/wordpress-subdirectorio-nginx-portfolio-php\/","title":{"rendered":"Mi portfolio sin framework: WordPress en subdirectorio, Nginx y SEO a mano"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Ten\u00eda mi portfolio en React y mi blog de WordPress en la ra\u00edz del dominio. Lo reconstru\u00ed entero: portfolio en PHP puro, sin framework ni bundler, y WordPress movido a un subdirectorio con Nginx, sin Apache ni <code>.htaccess<\/code>. Este art\u00edculo es el registro de qu\u00e9 decid\u00ed, por qu\u00e9, y los problemas reales que aparecieron al montarlo.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">No es teor\u00eda. Es lo que rompi\u00f3, lo que me cost\u00f3 entender y c\u00f3mo lo dej\u00e9 funcionando. Si vas a poner WordPress en un subdirectorio con Nginx, te ahorro varias horas.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Por qu\u00e9 dej\u00e9 React y volv\u00ed a PHP puro<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Mi portfolio es una p\u00e1gina. Texto, mis proyectos, enlaces, dos idiomas. Para eso ten\u00eda un stack de React con su bundler, su build, sus dependencias que envejecen. Cada vez que quer\u00eda tocar una l\u00ednea de texto, arrancaba un proceso de build. Para una p\u00e1gina est\u00e1tica con i18n, eso es traer una excavadora a plantar un geranio.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As\u00ed que lo tir\u00e9 y lo reescrib\u00ed en PHP plano. Sin framework, sin bundler, sin un solo kilobyte de JavaScript para renderizar la p\u00e1gina. El idioma se resuelve en el servidor: detecto por query param <code>?lang=<\/code>, si no hay miro la cookie, y si no caigo a ingl\u00e9s por defecto. El contenido vive en dos archivos, <code>lang\/en.php<\/code> y <code>lang\/es.php<\/code>, y el <code>index.php<\/code> los carga seg\u00fan toque.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">De paso mov\u00ed WordPress de la ra\u00edz a una carpeta <code>\/blog\/<\/code>. Quer\u00eda que el portfolio fuera la cara del dominio y el blog colgara de una URL limpia y natural. Esa decisi\u00f3n, que parec\u00eda trivial, fue la que m\u00e1s problemas de Nginx me trajo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">La arquitectura de un vistazo<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El mapa es sencillo. En la ra\u00edz, <code>index.php<\/code>: el portfolio, con i18n server-side y cero JavaScript. En <code>\/blog\/<\/code>, una instalaci\u00f3n de WordPress con un tema propio que llam\u00e9 \u00abfelipe\u00bb y el plugin Polylang para los dos idiomas.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El portfolio y el blog comparten apariencia pero no c\u00f3digo. El footer con los enlaces sociales est\u00e1 en los dos \u2014en PHP en el portfolio, en el tema en WordPress\u2014 con iconos SVG inline, sin librer\u00eda externa. Los lang files son la fuente \u00fanica del contenido del portfolio: ah\u00ed est\u00e1 todo el texto en los dos idiomas, y tambi\u00e9n las meta descriptions que uso para el SEO.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Dos mundos, entonces: PHP plano que controlo l\u00ednea a l\u00ednea, y WordPress para escribir y publicar c\u00f3modo. La frontera entre ambos es la carpeta <code>\/blog\/<\/code>, y ah\u00ed es donde Nginx tuvo que aprender a repartir el tr\u00e1fico.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Nginx sin .htaccess: los problemas reales<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Aqu\u00ed est\u00e1 la carne. Si vienes de Apache, est\u00e1s acostumbrado a que un <code>.htaccess<\/code> resuelva los rewrites por ti. En Nginx no existe eso: todo se declara en la configuraci\u00f3n del servidor, y WordPress en un subdirectorio no funciona hasta que se lo dices con precisi\u00f3n.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El primer golpe fueron las URLs de Polylang. El espa\u00f1ol queda en <code>\/blog\/<\/code> y el ingl\u00e9s en <code>\/blog\/en\/<\/code>, pero al entrar en <code>\/blog\/en\/<\/code> Nginx devolv\u00eda un 404. El problema: no hab\u00eda una regla que mandara esas rutas al <code>index.php<\/code> de WordPress. Lo arregla un <code>location \/blog\/<\/code> con <code>try_files<\/code> que, si el fichero o el directorio no existen, reescribe hacia el <code>index.php<\/code> del subdirectorio.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>location \/blog\/ {\n    try_files $uri $uri\/ \/blog\/index.php?$args;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Esa l\u00ednea es la que hace que WordPress en subdirectorio con Nginx funcione de verdad. Sin ella, cualquier permalink que no apunte a un archivo f\u00edsico se cae.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El segundo punto fue la base de datos. Al mover WordPress de la ra\u00edz a <code>\/blog\/<\/code>, las opciones <code>siteurl<\/code> y <code>home<\/code> segu\u00edan apuntando a la ra\u00edz, as\u00ed que WordPress se peleaba consigo mismo. Las actualic\u00e9 directamente en la base de datos a la nueva ruta y todo se reubic\u00f3.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Despu\u00e9s, dos reglas de limpieza. Bloque\u00e9 <code>xmlrpc.php<\/code>, que en una instalaci\u00f3n peque\u00f1a no me aporta nada y solo es superficie de ataque para fuerza bruta. Y a\u00f1ad\u00ed una regla para servir un <code>sitemap.php<\/code> din\u00e1mico bajo la URL <code>\/sitemap.xml<\/code>, porque quer\u00eda generar el sitemap con c\u00f3digo (luego cuento por qu\u00e9).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sobre Polylang: lo configur\u00e9 con <code>hide_default: true<\/code> y <code>default_lang: es<\/code>. As\u00ed el idioma por defecto, el espa\u00f1ol, no lleva prefijo y se queda en <code>\/blog\/<\/code>, mientras que el ingl\u00e9s vive en <code>\/blog\/en\/<\/code>. URLs limpias en el idioma principal, sin un <code>\/es\/<\/code> redundante colgando de todo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El tema m\u00ednimo y el SEO t\u00e9cnico a mano<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El tema de WordPress es deliberadamente m\u00ednimo. Tiene solo dos plantillas: <code>archive.php<\/code> para el listado y <code>single.php<\/code> para el post. No hay portada ni intento de replicar el portfolio dentro de WordPress; la cara del sitio es el <code>index.php<\/code> de la ra\u00edz, y el blog es el blog. El i18n del tema lo resuelvo con <code>pll_current_language()<\/code> de Polylang: los textos del archive, del single y del footer salen seg\u00fan el idioma activo. Las im\u00e1genes de los posts las fuerzo a cuadrado (1:1), pero solo en el archive, para que el listado quede regular.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El SEO t\u00e9cnico lo hice a mano, sin plugin. La meta description es din\u00e1mica por idioma, ley\u00e9ndola de los lang files. El <code>og:locale<\/code> cambia seg\u00fan el idioma (<code>es_ES<\/code> o <code>en_US<\/code>), la <code>og:image<\/code> y la <code>twitter:card<\/code> van como <code>summary_large_image<\/code>, y mont\u00e9 el set completo de favicons: el <code>.ico<\/code> cl\u00e1sico, la versi\u00f3n SVG y el <code>apple-touch-icon<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La pieza de la que estoy m\u00e1s contento es el sitemap. En vez de un XML est\u00e1tico o un plugin, escrib\u00ed un <code>sitemap.php<\/code> que carga WordPress y genera el <code>sitemap.xml<\/code> al vuelo. Incluye el portfolio con sus <code>hreflang<\/code> EN\/ES, las p\u00e1ginas del blog y todos los posts con sus traducciones de Polylang y la entrada <code>x-default<\/code>. Un sitemap din\u00e1mico en PHP con hreflang que se mantiene solo: cuando publico un post, ya est\u00e1 en el sitemap con sus dos idiomas, sin que yo toque nada.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Y un detalle de privacidad del que pocos se acuerdan: mi n\u00famero de WhatsApp no aparece nunca en el HTML. El enlace apunta a un <code>wa.php<\/code> que hace un redirect 302 en el servidor hacia WhatsApp. As\u00ed el n\u00famero no viaja al navegador y no lo pescan los scrapers que rastrean webs buscando tel\u00e9fonos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Dise\u00f1o e im\u00e1genes con Claude Design<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Us\u00e9 Claude Design en dos fases distintas. La primera, para la direcci\u00f3n de dise\u00f1o: el arranque visual del tema y las gu\u00edas de estilo. De ah\u00ed export\u00e9 HTML, y sobre ese HTML ajust\u00e9 las particularidades y met\u00ed los textos reales. Es decir, no part\u00ed de una p\u00e1gina en blanco pele\u00e1ndome con el CSS; part\u00ed de una base visual coherente y la afin\u00e9 a mano.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La segunda fase, ya con el dise\u00f1o decidido, fue de assets. Gener\u00e9 los iconos del sitio y los de WordPress, y las im\u00e1genes de las secciones del portfolio y de cada art\u00edculo. Tener una sola herramienta para la direcci\u00f3n de dise\u00f1o y para la imager\u00eda mantuvo todo coherente: los iconos, las im\u00e1genes de los posts y el aire general del sitio hablan el mismo idioma visual sin que yo tuviera que ser dise\u00f1ador.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Del local al servidor: FTP sobre LEMP<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El servidor es una VPS propia que autogestiono, con stack LEMP: Linux, Nginx, MySQL y PHP 8.3-FPM. Sin Apache en ning\u00fan punto.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00f3n. 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\u00e1 en producci\u00f3n. Para un sitio de este tama\u00f1o, montar un pipeline elaborado ser\u00eda resolver un problema que no tengo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Qu\u00e9 har\u00eda igual y qu\u00e9 cambiar\u00eda<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Lo de volver a PHP puro para el portfolio lo repetir\u00eda sin dudarlo. Para una p\u00e1gina con i18n y sin interactividad, React era peso muerto. Editar un texto y verlo en producci\u00f3n sin un build de por medio es exactamente la fricci\u00f3n que quer\u00eda quitarme.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Lo del sitemap din\u00e1mico en PHP tambi\u00e9n: cero mantenimiento y siempre correcto. Y los problemas de Nginx, una vez entendidos, son configuraci\u00f3n que escribes una vez y olvidas.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u00bfQu\u00e9 cambiar\u00eda? Probablemente el despliegue por FTP. Funciona y para este tama\u00f1o no me duele, pero un <code>git pull<\/code> por SSH ser\u00eda m\u00e1s limpio y menos propenso a subir un archivo a medias. No es urgente, pero es la pieza m\u00e1s artesanal del montaje y la primera que tocar\u00eda si el sitio creciera.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si te llevas una sola cosa de aqu\u00ed, que sea el <code>location \/blog\/<\/code> con <code>try_files<\/code>. Es la l\u00ednea que separa \u00abWordPress no carga en el subdirectorio y no s\u00e9 por qu\u00e9\u00bb de que todo funcione. El resto es trabajo; eso es el truco.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Pas\u00e9 mi portfolio de React a PHP puro sin framework ni bundler, y mov\u00ed WordPress a \/blog\/ sobre Nginx, sin Apache ni .htaccess. Cuento las decisiones y los problemas reales: el location \/blog\/ con try_files, las URLs de Polylang, el sitemap din\u00e1mico en PHP y el SEO t\u00e9cnico hecho a mano.<\/p>\n","protected":false},"author":1,"featured_media":239637,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,27],"tags":[49,35,51,53,29],"class_list":["post-239636","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog","category-desarrollo-web","tag-nginx","tag-php","tag-polylang","tag-seo-tecnico","tag-wordpress"],"_links":{"self":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239636","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/comments?post=239636"}],"version-history":[{"count":1,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239636\/revisions"}],"predecessor-version":[{"id":239638,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/posts\/239636\/revisions\/239638"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/media\/239637"}],"wp:attachment":[{"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/media?parent=239636"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/categories?post=239636"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/felipenespralsanchez.tech\/blog\/wp-json\/wp\/v2\/tags?post=239636"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}