Next.jsWordPressHashnodewebsite migrationwebsite development,GitHubJAMstack

How to Move A WordPress Website to Next.js and Hashnode

15 min readBy Adeyemi Adetilewa
How to Move A WordPress Website to Next.js and Hashnode

I have been building things online since 2013. In that time, I have used WordPress more times than I can count for client sites, for my own platforms, and for content-heavy publications like IdeasPlusBusiness.com. WordPress is familiar. It works. But somewhere along the way, familiar started feeling like a ceiling.

This is the story of how I rebuilt my personal website from scratch using Next.js 14, Hashnode as a headless CMS, GitHub for version control, and Vercel for deployments, and why, after years of building on WordPress, this stack finally feels right.

I am also going to walk you through how to do the same thing, step by step, whether you are starting from a WordPress site or from scratch.

Why I Left WordPress for This Stack

Let me be honest: WordPress did not break. I did not leave out of frustration or because something stopped working. I left because I wanted something I could build on my own terms.

WordPress carries weight. Plugins that need updating. Theme files that fight your customizations. A database you have to manage. Hosting costs that add up. Performance that requires constant attention. And if you want your site to look a certain way, you are either buying a theme and hoping it does what you need, or you are wrestling with PHP and template hierarchies.

I wanted a site that was mine at every layer. I wanted to control the design completely, write blog posts in a clean editor without worrying about blocks and plugins, and deploy updates the same way I deploy code: push to GitHub, done.

The stack I settled on does exactly that.

What This Stack Looks Like

Before diving into the how, here is what you are actually working with:

Next.js 14 (App Router) handles the frontend. It builds your pages as static HTML at deploy time, which means your site loads fast, every time, for every visitor. When you write a new blog post, only that page gets rebuilt — the rest stays cached. This is called Incremental Static Regeneration, and it is one of the reasons this approach is so good for content-heavy sites.

Hashnode is your writing environment. You write and publish posts there exactly as you would on a normal blogging platform. The difference is that Hashnode also exposes everything you write through a GraphQL API. Your Next.js site calls that API and displays your posts using your own design. Hashnode handles storage. You handle the presentation.

GitHub stores your code. Every file, every component, every change. It is also what triggers your deployments — when you push a change to your repo, Vercel picks it up automatically.

Vercel builds and hosts your site. It detects that you are using Next.js, runs the build process for you, and puts your site live. There is nothing to configure on a server. No FTP. No cPanel. Just connect your GitHub repo, and Vercel handles everything else.

What You Need Before You Start

  • A GitHub account (free at github.com)

  • A Vercel account (free at vercel.com)

  • A Hashnode account (free at hashnode.com)

  • Your existing WordPress content, or a clear idea of what pages you want

  • A text editor for editing files. VS Code works well, or you can edit directly on GitHub

  • About half a day, more if you are doing a full migration with lots of content

You do not need to know React deeply. You do not need to set up a local development environment if you prefer not to. I will show you a browser-based workflow that works entirely through GitHub and Vercel.

Step 1: Set Up Hashnode as Your CMS

Start here, before touching any code, because Hashnode is where your blog content will live.

Go to hashnode.com and create an account if you do not have one. Then click "Create blog" and choose a subdomain. I used adeyemiadetilewa.hashnode.dev. Use your name or brand name — something you will recognise.

Once your blog is created, go to your dashboard. This is where you will write posts from now on. The editor is clean, supports Markdown, lets you add cover images, set tags, and configure SEO metadata. It is genuinely better to write in than the WordPress block editor.

Before you leave Hashnode, note your publication host URL — it looks like yourname.hashnode.dev. You will need this when connecting your Next.js site.

You can also set up a custom domain in Hashnode so your posts are accessible at, say, blog.yourdomain.com —, but since we are using Next.js to display the posts on our main site, that is optional.

Step 2: Create Your Next.js Project Structure

If you are comfortable with a terminal, you can scaffold a Next.js project locally with:

npx create-next-app@latest your-site-name

Choose: TypeScript — No (or Yes if you prefer), Tailwind CSS — Yes, App Router — Yes.

If you would rather work entirely in the browser without running anything locally, you can manually create the files in a GitHub repo and let Vercel handle the build. This is what I did for a period when I was between machines, and it works fine.

Either way, your project structure should look like this:

your-site/
├── app/
│   ├── layout.js
│   ├── page.js
│   ├── about/
│   │   └── page.js
│   ├── blog/
│   │   ├── page.js
│   │   └── [slug]/
│   │       └── page.js
│   ├── portfolio/
│   │   └── page.js
│   └── contact/
│       └── page.js
├── components/
│   ├── Nav.js
│   └── Footer.js
├── lib/
│   └── hashnode.js
├── public/
│   └── (images, headshot, etc.)
├── .env.local.example
├── next.config.mjs
├── tailwind.config.js
└── package.json

The app/ folder is the Next.js App Router convention. Each page.js file corresponds to a route. The [slug] folder with square brackets is how Next.js handles dynamic routes — in this case, individual blog post pages.

That square bracket naming is important. If your folder is named slug without brackets, dynamic routing will not work and every blog post will return a 404.

Step 3: Connect to the Hashnode GraphQL API

Create a file at lib/hashnode.js. This is where all your Hashnode queries live. Here is the core setup:

const GQL_ENDPOINT = 'https://gql.hashnode.com';

function getHost() {
  return (
    process.env.NEXT_PUBLIC_HASHNODE_HOST ||
    'yourname.hashnode.dev'
  );
}

export async function getAllPosts(first = 20) {
  const host = getHost();
  const query = `
    query GetPosts(\(host: String!, \)first: Int!) {
      publication(host: $host) {
        posts(first: $first) {
          edges {
            node {
              title
              slug
              brief
              publishedAt
              readTimeInMinutes
              coverImage { url }
              tags { name slug }
            }
          }
        }
      }
    }
  `;

  const res = await fetch(GQL_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables: { host, first } }),
    cache: 'no-store',
  });

  const json = await res.json();
  return json.data?.publication?.posts?.edges?.map(e => e.node) ?? [];
}

A few things worth noting about this:

cache: 'no-store' tells Next.js to always fetch fresh from Hashnode rather than caching the API response. Combined with export const dynamic = 'force-dynamic' on your blog pages, this ensures your posts always appear as soon as you publish them.

The host variable — your Hashnode publication URL — is passed as a GraphQL variable. It must always be explicitly included in the variables object alongside any other variables like slug. If you pass it some other way, Hashnode will return errors about missing required fields.

For fetching individual posts, always build a separate standalone function rather than routing through a generic helper. Variables can get lost in spread operations:

export async function getPostBySlug(slug) {
  const host = getHost();
  
  const query = `
    query GetPost(\(host: String!, \)slug: String!) {
      publication(host: $host) {
        post(slug: $slug) {
          title
          slug
          brief
          publishedAt
          readTimeInMinutes
          content { html }
          coverImage { url }
          tags { name slug }
        }
      }
    }
  `;

  const res = await fetch(GQL_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query,
      variables: { host, slug },  // explicit — no spread
    }),
    cache: 'no-store',
  });

  const json = await res.json();
  return json.data?.publication?.post ?? null;
}

This pattern saved me hours of debugging. Keep your variables explicit and your fetches direct.

Step 4: Build Your Blog Pages

Your blog needs two pages: a listing page and a post page.

The listing page at app/blog/page.js calls getAllPosts() and renders the results. Add export const dynamic = 'force-dynamic' at the top so new posts appear immediately without a redeploy.

The post page at app/blog/[slug]/page.js is a dynamic route. Next.js reads the [slug] from the URL and passes it to your page as params.slug. Here is the key pattern:

export const dynamic = 'force-dynamic';
export const dynamicParams = true;

export default async function PostPage(props) {
  const params = await Promise.resolve(props.params);
  const slug = params?.slug;

  if (!slug) {
    // The [slug] folder is probably named wrong in GitHub
    return <p>Routing error: check your folder name</p>;
  }

  const post = await getPostBySlug(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content.html }} />
    </article>
  );
}

The await Promise.resolve(props.params) pattern ensures compatibility with both Next.js 14 and 15, where the params object changed from synchronous to asynchronous between versions.

dynamicParams = true is equally important. Without it, any post published after your last Vercel deployment will return a 404 because Next.js will only serve pre-built slugs. With it, new posts are rendered on demand on the first visit and then cached.

Step 5: Migrate Your WordPress Content

This is the step most people dread, and reasonably so. If you have years of WordPress content, you have a few options:

Export and re-publish manually — For a personal site with a manageable number of posts, this is the most reliable path. Export your WordPress posts as XML (Tools → Export in WordPress), use a tool like wordpress-export-to-markdown to convert them, then paste each one into Hashnode's editor. You keep full control over formatting, images, and metadata.

Use the WordPress REST API — If you have a large archive, you can write a script to fetch posts from your WordPress site's REST API (yoursite.com/wp-json/wp/v2/posts) and push them to Hashnode programmatically using Hashnode's mutation API. This is faster at scale but requires more setup.

Import via Hashnode's built-in tool — Hashnode has an import feature in the dashboard under Settings → Import. It accepts WordPress XML exports and handles the conversion. Image hosting is the main thing to watch here — imported images still point to your old WordPress domain until you update them.

For images specifically, I recommend uploading them directly to Hashnode's media library during the migration rather than keeping external references. It removes a dependency on your old WordPress host.

Step 6: Set Up Your GitHub Repository

Create a new repository on GitHub. Name it something like your-name-site. Set it to private if you prefer, though public is fine too.

If you have been working locally, push your project:

git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/yourusername/your-site.git
git push -u origin main

If you are working in a browser-only environment, use GitHub's upload interface. Drag and drop your project files and commit.

One critical thing: do not upload a .env.local file to GitHub. It contains your environment variables and should stay local. The .gitignore file that comes with a Next.js project already excludes it, but double-check.

Step 7: Deploy to Vercel

Go to vercel.com, create an account, and click "Add New Project." Import your GitHub repository. Vercel will detect Next.js automatically.

Before clicking Deploy, open the Environment Variables section and add:

NEXT_PUBLIC_HASHNODE_HOST = yourname.hashnode.dev

Replace yourname.hashnode.dev with your actual Hashnode publication host, without https:// and without a trailing slash.

Click Deploy. Vercel will run npm install and npm run build for you. If the build succeeds, your site is live at a yourproject.vercel.app URL.

If the build fails, check the build logs — Vercel surfaces errors clearly. Most common issues are missing environment variables, incorrect import paths, or a component that imports a browser-only API without a 'use client' directive.

Step 8: Connect Your Custom Domain

In your Vercel project dashboard, go to Settings → Domains and add your custom domain.

Vercel will show you two DNS records to add at your domain registrar — an A record and a CNAME. Add both, wait a few minutes for DNS to propagate, and your site will be live at your own domain.

If you are migrating from a live WordPress site, I recommend keeping WordPress running on a subdomain like old.yourdomain.com while you test the new site. Once you are satisfied, point the main domain to Vercel and decommission WordPress.

Step 9: Set Up the Hashnode Webhook for Instant Publishing

Without a webhook, new posts appear on your site only when Vercel rebuilds — which happens on every GitHub push but not automatically when you publish on Hashnode.

The fix is a revalidation webhook. Create this file in your project at app/api/revalidate/route.js:

import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get('secret');

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  const body = await request.json();
  const slug = body?.data?.post?.slug;

  revalidatePath('/blog');
  revalidatePath('/');
  if (slug) revalidatePath(`/blog/${slug}`);

  return NextResponse.json({ revalidated: true });
}

Then in your Vercel environment variables, add REVALIDATE_SECRET with any random string.

In your Hashnode dashboard, go to Settings → Webhooks, add a new webhook pointing to:

https://yourdomain.com/api/revalidate?secret=your_secret_here

Select the "Post published" and "Post updated" events. Now, every time you publish or edit a post on Hashnode, your site updates within seconds — no GitHub push needed.

What I Ran Into and How I Fixed It

Building this taught me a few things that cost me time, and that I want to save you.

The slug was undefined. The most persistent issue I hit was blog posts returning errors with the message "Variable $slug was not provided." After a lot of investigation, the fix was to make getPostBySlug its own standalone fetch function with variables built explicitly, never using a spread to merge them into a shared helper. Explicit variable construction in the request body eliminated the issue completely.

The [slug] folder name matters exactly. On some operating systems and some file upload tools, square brackets in folder names are handled inconsistently. If your folder is named slug instead of [slug], every blog post will 404 because the dynamic routing never activates. Check this carefully after uploading to GitHub.

The seo field is not available on all Hashnode tiers. An earlier version of my query requested seo { title description } from the Hashnode API. That field caused GraphQL errors on my publication type. Removing it and handling SEO metadata directly in the Next.js generateMetadata function resolved the issue cleanly.

Images need to be in next.config.mjs. If you display images from Hashnode's CDN using Next.js's <Image /> component, you have to add cdn.hashnode.com to the remotePatterns in your Next.js config. Without it, the build fails with an error about unauthorized image domains.

The Workflow Now

This is what my publishing workflow looks like today, after all of this:

When I want to write a post, I open Hashnode, write in their editor, add a cover image, set the tags, and hit publish. The webhook fires. Within about 60 seconds, the post is live on my site at adeyemiadetilewa.com/blog/post-slug, styled with my design, in my fonts, on my domain.

When I want to update something on the site itself — a new project, a copy change, a design tweak — I edit the relevant file directly on GitHub, commit, and Vercel deploys automatically. The whole thing takes about 90 seconds.

There is no WordPress admin to log into. No plugin updates to run. No theme conflicts to resolve. No server to manage. The site loads fast because it is mostly static HTML. It costs nothing to run beyond the domain registration.

For someone who has spent years managing WordPress sites for clients and for myself, this simplicity feels like a genuine upgrade.

Is This Stack for You?

It depends on what you are building and how comfortable you are working with code files.

If you are a developer or comfortable editing code in a text editor, this is an excellent choice for a personal site or portfolio. The workflow is clean, the performance is excellent, and you have complete control over every detail.

If you are a content creator who just wants to write and have things work, WordPress or a hosted Hashnode site may still be the right call. The stack I am describing involves editing JavaScript files. That is a real consideration.

If you are somewhere in the middle — you can follow code without writing it from scratch, you know how to use GitHub, and you are willing to learn a few new things — this is very achievable. The files are readable. The errors are usually descriptive. And AI tools like Claude can help you debug and build specific features when you get stuck.

What You End Up With

At the end of this process, you have:

  • A fast, statically generated website with full design control

  • A clean writing environment on Hashnode that publishes to your site automatically

  • A GitHub repository you can edit from any browser

  • Automatic deployments on every change

  • No monthly hosting costs beyond your domain

The WordPress site I migrated from took more time to maintain than to use. This one takes almost no time to maintain and gets out of the way when I want to write.

That alone makes the migration worth it.