My portfolio was a React app and my WordPress blog sat at the domain root. I rebuilt the whole thing: portfolio in plain PHP, no framework or bundler, and WordPress moved into a subdirectory on Nginx, with no Apache and no .htaccess. This article is the record of what I decided, why, and the real problems that showed up while building it.

This isn’t theory. It’s what broke, what took me a while to understand, and how I got it running. If you’re about to put WordPress in a subdirectory with Nginx, I’ll save you a few hours.

Why I dropped React and went back to plain PHP

My portfolio is one page. Text, my projects, links, two languages. For that I had a React stack with its bundler, its build, its dependencies that age. Every time I wanted to change a line of text, I had to kick off a build process. For a static page with i18n, that’s bringing in an excavator to plant a geranium.

So I scrapped it and rewrote it in plain PHP. No framework, no bundler, not a single kilobyte of JavaScript to render the page. Language is resolved on the server: I detect it from the ?lang= query param, then fall back to a cookie, and if there’s neither I default to English. The content lives in two files, lang/en.php and lang/es.php, and index.php loads whichever applies.

While I was at it, I moved WordPress from the root into a /blog/ folder. I wanted the portfolio to be the face of the domain and the blog to hang off a clean, natural URL. That decision, which looked trivial, was the one that caused me the most Nginx headaches.

The architecture at a glance

The map is simple. At the root, index.php: the portfolio, with server-side i18n and zero JavaScript. At /blog/, a WordPress install with a custom theme I called “felipe” and the Polylang plugin for the two languages.

The portfolio and the blog share a look but not code. The footer with the social links lives in both —in PHP on the portfolio, in the theme on WordPress— with inline SVG icons, no external library. The lang files are the single source of the portfolio’s content: that’s where all the text in both languages lives, along with the meta descriptions I use for SEO.

Two worlds, then: plain PHP that I control line by line, and WordPress for writing and publishing comfortably. The border between them is the /blog/ folder, and that’s where Nginx had to learn how to route the traffic.

Nginx with no .htaccess: the real problems

Here’s the meat. If you come from Apache, you’re used to an .htaccess handling the rewrites for you. There’s no such thing in Nginx: everything is declared in the server config, and WordPress in a subdirectory doesn’t work until you spell it out precisely.

The first hit was the Polylang URLs. Spanish stays at /blog/ and English at /blog/en/, but loading /blog/en/ made Nginx return a 404. The problem: there was no rule sending those paths to the WordPress index.php. It’s fixed by a location /blog/ with try_files that, when the file or directory doesn’t exist, rewrites to the subdirectory’s index.php.

location /blog/ {
    try_files $uri $uri/ /blog/index.php?$args;
}

That line is what actually makes WordPress in a subdirectory with Nginx work. Without it, any permalink that doesn’t point to a physical file falls over.

The second point was the database. When I moved WordPress from the root to /blog/, the siteurl and home options still pointed at the root, so WordPress was fighting itself. I updated them directly in the database to the new path and everything relocated.

Then, two cleanup rules. I blocked xmlrpc.php, which adds nothing on a small install and is just attack surface for brute-force attempts. And I added a rule to serve a dynamic sitemap.php under the /sitemap.xml URL, because I wanted to generate the sitemap with code (more on why in a moment).

On Polylang: I configured it with hide_default: true and default_lang: es. That way the default language, Spanish, carries no prefix and stays at /blog/, while English lives at /blog/en/. Clean URLs in the main language, with no redundant /es/ hanging off everything.

The minimal theme and hand-rolled technical SEO

The WordPress theme is deliberately minimal. It has only two templates: archive.php for the listing and single.php for the post. There’s no homepage, no attempt to replicate the portfolio inside WordPress; the face of the site is the root index.php, and the blog is the blog. I handle the theme’s i18n with Polylang’s pll_current_language(): the archive, single, and footer text comes out according to the active language. I force the post images to square (1:1), but only in the archive, so the listing stays even.

I did the technical SEO by hand, with no plugin. The meta description is dynamic per language, read from the lang files. The og:locale changes with the language (es_ES or en_US), the og:image and twitter:card go as summary_large_image, and I built the full favicon set: the classic .ico, the SVG version, and the apple-touch-icon.

The piece I’m happiest with is the sitemap. Instead of a static XML or a plugin, I wrote a sitemap.php that loads WordPress and generates the sitemap.xml on the fly. It includes the portfolio with its EN/ES hreflang tags, the blog pages, and every post with its Polylang translations and the x-default entry. A dynamic PHP sitemap with hreflang that maintains itself: when I publish a post, it’s already in the sitemap with both languages, without me touching a thing.

And a privacy detail few people remember: my WhatsApp number never appears in the HTML. The link points to a wa.php that does a 302 redirect on the server toward WhatsApp. That way the number never travels to the browser and isn’t picked up by scrapers crawling sites for phone numbers.

Design and images with Claude Design

I used Claude Design in two distinct phases. The first, for design direction: the visual starting point of the theme and the style guidelines. From there I exported HTML, and on top of that HTML I adjusted the specifics and dropped in the real text. So I didn’t start from a blank page wrestling with CSS; I started from a coherent visual base and refined it by hand.

The second phase, with the design already decided, was assets. I generated the site icons and the WordPress ones, and the images for the portfolio sections and for each article. Having a single tool for both design direction and imagery kept everything coherent: the icons, the post images, and the general feel of the site speak the same visual language without me having to be a designer.

From local to server: FTP over LEMP

The server is a self-managed VPS of my own, running a LEMP stack: Linux, Nginx, MySQL, and PHP 8.3-FPM. No Apache anywhere.

The deploy is deliberately unsophisticated: I upload the files over FTP, the same flow for the plain PHP portfolio and for WordPress. There’s no CI pipeline, no build to run, no compilation step. And that’s exactly one of the upsides of going back to plain PHP: what I edit is what gets served. I upload the file and it’s in production. For a site this size, setting up an elaborate pipeline would be solving a problem I don’t have.

What I’d do the same and what I’d change

Going back to plain PHP for the portfolio I’d do again without hesitation. For a page with i18n and no interactivity, React was dead weight. Editing a line of text and seeing it in production with no build in between is exactly the friction I wanted gone.

The dynamic PHP sitemap too: zero maintenance and always correct. And the Nginx problems, once understood, are config you write once and forget.

What would I change? Probably the FTP deploy. It works and at this size it doesn’t hurt, but a git pull over SSH would be cleaner and less likely to push a half-uploaded file. It’s not urgent, but it’s the most hand-cranked part of the setup and the first thing I’d touch if the site grew.

If you take one thing away from this, make it the location /blog/ with try_files. It’s the line that separates “WordPress won’t load in the subdirectory and I don’t know why” from everything working. The rest is just work; that’s the trick.