My portfolio has been running on WordPress for years. It worked fine at first. WordPress on shared hosting, WPGraphQL to serve content to a Next.js frontend, Advanced Custom Fields for structured data like project URLs and experience dates. The usual headless WordPress setup.
But it was getting old. Not in a "this needs a redesign" way. More in a "why am I maintaining a whole WordPress installation just to serve a handful of projects and blog posts" kind of way.
The WordPress Problem
Here's what my setup looked like before the migration:
- Shared hosting running WordPress
- WPGraphQL plugin to expose content as a GraphQL API
- ACF (Advanced Custom Fields) for project metadata like status, live URLs, tech stack
- Yoast SEO for meta descriptions and reading time estimates
- Next.js frontend on Vercel, fetching everything through GraphQL
It worked, but there were problems I kept ignoring. The GraphQL queries were brittle. Every time WordPress pushed a plugin update, something would quietly break. The ACF fields were a mess of meta key/value pairs that required multiple JOINs just to get a project's GitHub URL. And I was paying monthly hosting fees for what was basically a glorified JSON file.
The worst part? I had to log into WordPress just to fix a typo in a project description. For my own portfolio. That I built from scratch.
So I decided to rip the whole thing out.
The Supabase Detour
My first thought was to move everything to Supabase. It's the popular choice right now. Postgres database, built-in auth, storage buckets, real-time subscriptions, all in one platform. Sounds perfect on paper.
I started planning the migration. Schema design, Row Level Security policies, storage buckets for images, the whole nine yards. Then I took a hard look at what I actually needed:
- A Postgres database with maybe 10 tables
- pgvector support for RAG embeddings (I was building an AI chat feature for the site)
- Object storage for about 40 images, less than 50MB total
- Auth for exactly one user. Me.
Supabase Pro is $25/month. For a portfolio site. And I already had other projects on Supabase eating into the free tier limits. Starting a new project there would mean either juggling free tier restrictions or paying for a Pro plan I didn't need 90% of.
I needed something that matched the actual scale of what I was building.
Landing on Railway + Cloudflare R2
That's when I found Railway. If you haven't come across it yet, Railway is a platform where you deploy databases and services and you only pay for what your app actually uses. No provisioning instances. No picking box sizes. You deploy, and billing scales with your usage down to the second.
Here's what got me on board:
Railway Postgres costs about $5/month for my usage level. Full Postgres. Not a stripped-down variant or some proprietary query layer on top. Actual Postgres where I can create extensions like pgvector and uuid-ossp, write PL/pgSQL functions, and connect with any standard Postgres client. They also support one-click deploys for databases, so getting started took about 30 seconds.
Cloudflare R2 for media storage was the other piece. The big selling point here is zero egress fees. Every time someone visits my site and loads a project thumbnail, that image gets served through Cloudflare's global CDN at no cost. The free tier gives you 10GB of storage and 10 million read requests per month. I'm using maybe 50MB.
The combined monthly cost dropped from what I was paying before to roughly $5. For a much better architecture.
The Migration
This was the interesting part. I had to get everything out of WordPress and into a completely different data structure.
Exporting from WordPress
WordPress stores content in a... creative way. Blog posts live in wp_posts as Gutenberg block HTML. Every paragraph, heading, and image is wrapped in HTML comments like <!-- wp:paragraph -->. Project metadata from ACF? Scattered across a postmeta table as loosely connected key-value pairs. Featured images aren't stored on the post itself. They're separate attachment posts linked through a meta field that points to another post ID. Experience dates from ACF use YYYYMMDD format.
I exported the full database as JSON and wrote a migration script in TypeScript to handle all the transformations. The script:
- Reads the WordPress JSON export
- Strips all Gutenberg block comments with regex
- Decodes HTML entities
- Converts ACF date formats to ISO standard
- Resolves featured image references by following the attachment ID chain
- Downloads all 40 media files and uploads them to Cloudflare R2
- Inserts everything into Railway Postgres with proper relational structure
Categories in WordPress were another puzzle. They use a three-table system: terms, term taxonomies, and term relationships. I had to join all three just to figure out which blog post was filed under which category.
The whole migration script ended up being around 500 lines. Not because the transformations were complex, but because WordPress has so many edge cases in how it stores data.
The New Schema
The Postgres schema on Railway is dramatically cleaner. Instead of chasing data through meta tables:
blogshas actual columns for everything: title, slug, content, excerpt, featured image, SEO fields, reading time, published statusprojectsstores all the metadata that was previously in ACF as proper typed columns with text arrays for tech stacksexperienceshas start and end dates as real date columns, not eight-digit strings- A
knowledge_basetable with pgvector embeddings powers the AI chat feature - Category relationships are handled through clean many-to-many junction tables
No more hunting through 482 rows of postmeta to figure out a project's GitHub URL.
Media to Cloudflare R2
Moving the media files was actually the easiest part. The migration script fetches each WordPress attachment from its original URL, determines the content type, and uploads it to R2 using the S3-compatible API. I pointed a custom subdomain through Cloudflare DNS for clean image URLs instead of using the raw R2 bucket address.
Building the Admin Panel
This was the part I didn't think about until WordPress was already gone.
With WordPress out of the picture, I had no CMS. No way to edit content without writing SQL. So I built a custom admin panel directly into the Next.js app.
It has markdown editors for blog posts and project descriptions (content gets stored as HTML but you write in markdown), image uploads that go straight to R2, category management, a media library with grid and list views, draft/publish toggling, and autosave so you don't lose your work if you close the tab by accident.
I also built editors for experiences and a skills manager, since those were previously managed through ACF fields in WordPress.
Could I have used a headless CMS like Strapi or Payload instead? Sure. But that's another service to host, another set of credentials to manage, another dependency that can break independently. The admin panel lives in the same codebase as the rest of the site. Same deployment. Same database. Nothing extra.
The AI Chat Feature
This is where Railway's pgvector support really paid off. I added a conversational AI interface to the portfolio. Think of it as a ChatGPT-style chat that actually knows about my work, my projects, my skills, and my experience.
It uses RAG (Retrieval Augmented Generation). When someone asks a question, the system generates a vector embedding of the query, runs a cosine similarity search against a knowledge base stored in Postgres, retrieves the most relevant content chunks, and injects that context into the LLM's system prompt before streaming a response.
All of this runs on the same Railway Postgres instance. Vector search on a small knowledge base takes single-digit milliseconds. No need for a separate vector database service. No Pinecone, no Weaviate, no extra monthly bill.
I also added slash commands (/skills, /projects, /experience) and the chat persists conversations in the browser with a 7-day expiry so returning visitors can pick up where they left off.
The Numbers
Here's what changed:
| Before (WordPress) | After (Railway + R2) | |
|---|---|---|
| Monthly cost | Higher | ~$5 |
| Content query layer | GraphQL via WPGraphQL plugin | Direct SQL with postgres.js |
| Media storage | Shared hosting | Cloudflare R2 (free tier, global CDN) |
| Admin panel | WordPress Dashboard | Custom-built in Next.js |
| Vector search for AI | Not possible | pgvector on Railway |
| Deployment steps | WordPress + Vercel | Just Vercel |
| Content update workflow | Log into WordPress admin | Edit in my own admin panel |
The biggest win honestly isn't the cost. It's that everything lives in one place now. One codebase, one database, one deployment pipeline. When I want to update something, I open VS Code or the admin panel. No more switching between WordPress and my actual development environment.
What I Took Away From This
A few honest observations after going through the whole process:
WordPress is great until your needs don't match what it was designed for. For a decoupled headless setup where you control the frontend, the overhead of plugins, updates, security patches, and hosting costs adds up for very little return. The content was always coming through an API anyway.
Evaluate based on your actual scale. Tools like Supabase, Firebase, and PlanetScale are excellent. But for a portfolio site with a few dozen pages of content and one admin user, a plain Postgres database and an S3-compatible storage bucket handles everything without the overhead.
Write migration scripts, don't copy-paste. I moved 16 projects, 6 work experiences, 4 blog posts, 40 media files, and 25+ categories across taxonomies. Doing that by hand would have been error-prone and miserable. Having a script means it's repeatable, documented, and you can run it again if something goes wrong.
Railway is worth trying. Deploy a Postgres database in half a minute, enable pgvector with one SQL command, and pay only for what you use. No upfront plans, no idle resource charges. For side projects and portfolio sites, it's hard to beat.
Would I Recommend This?
If you're comfortable writing SQL and you're already building the frontend yourself, absolutely. The migration is a weekend project if you script it properly, and the ongoing maintenance afterward is close to zero.
If WordPress is genuinely serving you well and you're not fighting against it, leave it alone. Migrations for the sake of migrations are a waste of time.
But if you've been staring at your wp-admin dashboard thinking "there has to be a better way to run a personal site"... there probably is.
