This is the second part of our WordPress to Payload migration series.
In this post, we're going to take care of migrating our visible website front end.
Recap: In the last video, I showed you how we can take Pages that we have in WordPress and turn them into a Pages collection in Payload, and migrate the data from our Posts with an Advanced Custom Fields plugin enabled. By doing that, we created the new Post collection in Payload. We also automatically migrated all of our media elements—images and videos—from WordPress to Payload. We then created post authors, post categories, and a global header and footer for the actual menus that we had in WordPress. (One navigation menu in the header and one in the footer).
We also went ahead and created a Payload block schema for each Gutenberg block that we had in WordPress. In WordPress, whether you have pages or posts, they both consist of a layout built with Gutenberg blocks. In our example, we had three blocks, so we created a schema for each one: a cover block (a hero section with a heading), a paragraph block (rich text), and an image block (contains an image).
Finally, we created a block to show the latest blog posts (the last three posts automatically). This is something WordPress handles automatically as a page, but in Payload, it’s much more flexible—you can place that block wherever you want, like on the homepage or the /blog
page. We also created a block for the header and one for the footer.
It's important to note that the front end will need to be rebuilt manually. This is what we're going to walk through. You can't just run a migration script and have your front end render, because in Payload, it’s different from WordPress. You need to build the front end yourself. In this case, we’re using Next.js as the front end framework. This means you’ll need to rebuild every visible block in your front-end framework language.
This is also an excellent opportunity to redesign your site.
Often, a design/theme has been adjusted somewhat to fit within WordPress. In this case, you are completely free to do whatever you want. You might adjust the design here and there. To make things easier, you can use component libraries like Tailwind UI (paid) or Tailwindcomponents.com, where some components are free. These can act as foundations that you can build your new blocks upon.
Also, there are helper tools like Windy (a Chrome extension) that allow you to go to your old page, hover over sections, and copy the Tailwind classes you need to build your new Payload blocks.
We're going to start by adding Tailwind CSS support to our project. Now, you don't have to use Tailwind to style your components or your blocks, but it makes things a lot faster. So, I'm going to go ahead and install Tailwind.
Before we can do that, though, we need to create a root layout file, which is basically a file that we put in the root app folder since we're using the app router here. So, I’m going to create layout.tsx
file in our app directory, which is just a wrapper for everything. You can leave it as it is—it won’t do anything until we adjust it, but we need this to import our Tailwind styles.
js export default function RootLayout({ // Layouts must accept a children prop. // This will be populated with nested layouts or pages children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ) }
Next, I'm going to go to the official Tailwind CSS Next.js installation guide. I can skip the first step because we don’t need to create a new project, but I’m going to install Tailwind. I’m actually going to use pnpm
, so I’ll just copy part of the command and run pnpm install -D tailwindcss postcss autoprefixer
from the VS Code terminal, followed by pnpx tailwindcss init
, which should create a Tailwind config file and a PostCSS config file.
In our Tailwind config file, the only thing I’m going to add is the content path, so Tailwind knows where our content is located, which is in the src
folder.
Example:
/** @type {import('tailwindcss').Config} */
export default {
content: [
// Added the following line to include all relevant file types in the src directory
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
}
I’m going to create a new styles
folder, where I’ll create globals.css
, importing the Tailwind layers.
@tailwind base;
@tailwind components;
@tailwind utilities;
Now, we need to go to our root layout (layout.tsx) and import it. I’ll add:
import '@styles/globals.css';
Now, since this layout contains the Payload routes as well, you can also use Tailwind CSS within your Payload admin panel to style your custom components.
I’m going back to the app
directory. First, I’ll get rid of the default my-route
—this is just boilerplate. I’ll create a new route segment and call it (website)
. This is just for keeping your codebase a bit more organized.
In Next.js, with the app router, we have file-based routing.
src/ ├── app/ │ ├── (payload)/ │ └── (website)/ ├── layout.tsx └── blocks/
This means that if I create a normal folder here, everything in that folder will be at /website/whatever
. That’s why I’m creating this folder for the website.
Now, we need to create another layout file just for our website, where we’re going to import our header
and footer
blocks because they should be globally placed and visible throughout the entire website. I’ll create layout.tsx
within website
for this purpose.
It’s basically a simple React component. The only important prop is children
, which you just need to put in there. We’ll add the header and footer later.
import React, { ReactNode } from 'react'
export default function layout({children}: {children: ReactNode}) {
return (
<div>
{/* Header */}
{children}
{/* Footer */}
</div>
)
}
To add them, I’m going into the blocks
directory and creating a new subdirectory called global
for global blocks. Inside, I’ll create header
and footer
. Let’s start with the header. I’m going to create a new file called Server.tsx
. You could also call it headerServer
or whatever, but the key part is that this is a server component, which contains a client component. I’m going to set it up as an RFC (React Functional Component).
import React from 'react';
export default function HeaderServer() {
return (
<div>
Header
</div>
);
}
Now, for the footer, we’ll do the same thing with server.tsx
.
import React from 'react';
export default function FooterServer() {
return (
<div>
Footer
</div>
);
}
Next, we’ll import them into our website layout:
import React from 'react';
export default function HeaderServer() {
return (
<div>
Header
</div>
);
}
Let's restart our project and see if everything appears.
To test if the header and footer are displayed, we're just going to create a page.tsx
file on the root path, which is empty. I’ll just say page
for now. We should see that we have the header, the page, and the footer.
(Note: It seems like I forgot to show you how to create the header and footer global in the last video, so I’ll do that now. It’s not very hard. To create them, I’m going back into our src
folder, creating a globals
subdirectory, and adding header.ts
and footer.ts
. In header.ts
, I’ll export a constant header
, which is a global config. Below are the code snippets for each).
header.ts:
import { GlobalConfig } from 'payload';
export const Header: GlobalConfig = {
slug: 'header',
fields: [
{
name: 'name',
type: 'text',
label: 'Name',
required: true,
},
{
name: 'navigationItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
label: 'Label',
required: true,
},
{
name: 'link',
type: 'text',
label: 'Link',
required: true,
},
],
},
],
};
footer.ts:
import { GlobalConfig } from 'payload';
export const Footer: GlobalConfig = {
slug: 'footer',
fields: [
{
name: 'name',
type: 'text',
label: 'Name',
required: true,
},
{
name: 'navigationItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
label: 'Label',
required: true,
},
{
name: 'link',
type: 'text',
label: 'Link',
required: true,
},
],
},
],
};
And we'll add the following to our Payload config file below collections
to ensure it surfaces in our admin panel:
globals: [
Header,
Footer,
],
If we go back to our admin panel, we can see that we have a global header and a global footer.
Within the admin panel, I'll now add a header link to Google, and also add a link to our internal blog page (which we’ll create later) as our first element. For the footer, I’ll do the same thing, but with a link to Facebook.
Now, we need to fetch this global header and footer data in our header and footer blocks. The app router and server components make this very easy. We basically need to turn the exported function into an async function, and then use Payload’s local API, which is faster than going through the REST API and is type-safe:
Within header > Server.tsx
...
import React from 'react';
import config from '@payload.config';
import { getPayloadHMR } from '@payloadcms/next/utilities';
export default async function HeaderServer() {
const payload = await getPayloadHMR({ config });
const header = await payload.findGlobal({
slug: 'header'
});
return (
<div>
{header.name}
<div className="flex gap-1 items-center">
{header.navigationItems.map((item: any) => {
return (
<a href={item.link}>{item.label}</a>
);
})}
</div>
</div>
);
}
Now, we can display the header.name
and map through the navigationItems
to display them as links.
You should now see your WP migration page with the two links (e.g., “Google” and “Blog”) in the header. Let’s style it real quick using Tailwind CSS. I’ll add padding and center the items using Tailwind classes.
Within header > Server.tsx
...
import React from 'react';
import config from '@payload.config';
import { getPayloadHMR } from '@payloadcms/next/utilities';
export default async function HeaderServer() {
const payload = await getPayloadHMR({ config });
const header = await payload.findGlobal({
slug: 'header'
});
return (
<div className="py-12">
<div className="mx-auto max-w-5xl flex justify-between">
<div className="text-2xl font-medium">
{header.name}
</div>
<div className="flex gap-4 items-center">
{header.navigationItems.map((item: any) => {
return (
<a href={item.link}>{item.label}</a>
);
})}
</div>
</div>
</div>
);
}
The same goes for the footer. We can add a border and a light background to distinguish it.
Within `footer > Server.tsx` ...
import React from 'react'; import config from '@payload.config'; import { getPayloadHMR } from '@payloadcms/next/utilities';
export default async function FooterServer() { const payload = await getPayloadHMR({ config });
const header = await payload.findGlobal({ slug: 'footer' });
return ( <div className="py-12 border -t border -t-gray-500 bg-gray-50"> <div className="mx-auto max-w-5xl flex justify-between"> <div className="text-2xl font-medium"> {header.name} </div> <div className="flex gap-4 items-center"> {header.navigationItems.map((item: any) => { return ( <a href={item.link}>{item.label}</a> ); })} </div> </div> </div> ); }
Now that we have our header, footer, and an empty page in between, let’s implement into the frontend how to display all of our pages that we created in the backend (the homepage and the blogs page). For this, we need to create a dynamic route.
Inside `website`, I’ll create a new folder named `[slug]`. Inside `[slug]`, I’ll create a `page.tsx` file and paste in the code snippet to fetch and render the page layout based on the slug.
import type { Metadata } from 'next'
import config from '@payload-config' import { getPayloadHMR } from '@payloadcms/next/utilities' import React, { cache } from 'react'
import type { Page as PageType } from '../../../payload-types'
import { Blocks } from '@/utils/RenderBlocks' import { generateMeta } from '@/utils/generateMeta' import { notFound } from 'next/navigation'
const queryPageBySlug = cache(async ({ slug }: { slug: string }) => {
const parsedSlug = decodeURIComponent(slug)
const payload = await getPayloadHMR({ config })
const result = await payload.find({
collection: 'pages',
limit: 1,
where: {
slug: {
equals: parsedSlug,
},
},
})
return result.docs?.[0] || null
})
export async function generateStaticParams() { const payload = await getPayloadHMR({ config }) const pages = await payload.find({ collection: 'pages', draft: false, limit: 1000, })
return pages.docs ?.filter((doc) => { return doc.slug !== 'index' }) .map(({ slug }) => slug) }
export default async function Page({ params: { slug = 'index' } }) { let page: PageType | null
page = await queryPageBySlug({ slug, })
if (!page) { return notFound() }
return (
<div className="pt-16 pb-24"> {JSON.stringify(page.layout)} {/* <RenderBlocks blocks={page.layout} /> */} </div>) }
export async function generateMetadata({ params: { slug = 'index' } }): Promise<Metadata> { const page = await queryPageBySlug({ slug, })
return generateMeta({ doc: page }) }
For definitions around this code snippet, and how it operates, you can [see the timestamped video](https://youtu.be/dnvQLFAXKw0?t=1191).
In our `src` folder we're going to create a `utils` folder. In there, we'll create a `generateMeta.ts` file and paste the following:
import type { Metadata } from 'next'
import { Page, BlogPost } from '@/payload-types'
const defaultOpenGraph: Metadata['openGraph'] = {
type: 'website',
description: 'An open-source website built with Payload and Next.js.',
images: [
{
url: process.env.NEXT_PUBLIC_URL
? `${process.env.NEXT_PUBLIC_URL}/website-template-OG.webp`
: '/website-template-OG.webp',
},
],
siteName: 'Payload WP Migration',
title: 'Payload WP Migration',
}
export const mergeOpenGraph = (og?: Metadata['openGraph']): Metadata['openGraph'] => {
return {
...defaultOpenGraph,
...og,
images: og?.images ? og.images : defaultOpenGraph.images,
}
}
export const generateMeta = async (args: { doc: Page | BlogPost }): Promise<Metadata> => {
const { doc } = args || {}
const ogImage =
typeof doc?.meta?.image === 'object' &&
doc.meta.image !== null &&
'url' in doc.meta.image &&
`${process.env.NEXT_PUBLIC_URL}${doc.meta.image.url}`
const title = doc?.meta?.title
? doc?.meta?.title + ' | Payload WP Migration'
: 'Payload WP Migration'
return {
description: doc?.meta?.description,
openGraph: mergeOpenGraph({
description: doc?.meta?.description,
images: ogImage
? [
{
url: ogImage,
},
]
: undefined,
title,
url: Array.isArray(doc?.slug) ? doc?.slug.join('/') : '/',
}),
title,
}
}
This is to handle open graph data, for social media, etc. You may need to adjust lines such as `siteName` or `title` for your own purposes.
Relatedly, however, [we must create a `public` folder (I placed it above `src`) and upload a fallback image](https://youtu.be/dnvQLFAXKw0?t=1434) if there's no SEO data. This will be referenced here `${process.env.NEXT_PUBLIC_URL}/website-template-OG.webp` and here: '/website-template-OG.webp'.
For an explanation on this, [see this timestamp in the video](https://youtu.be/dnvQLFAXKw0?t=1449).
To ensure we have an index page, we're going to create a `page.tsx` and place it within the `(website)` folder.
import PageTemplate, { generateMetadata } from './[slug]/page';
export default PageTemplate;
export { generateMetadata };
This imports functions from the dynamic one.
To replace the raw JSON data with actual blocks, we’ll need another utility function called `renderBlocks` within the `utils` folder. This function will check if the page or post has any blocks and map them to their corresponding React components. For example, if we have a cover block with a heading and subheading, it will pass these as props to the front-end component.
RenderBlocks.tsx:
import { Page } from '@/payload-types' import React, { Fragment } from 'react'
const blockComponents = {
}
export const RenderBlocks: React.FC<{ blocks: Page['layout'][0][] }> = (props) => { const { blocks } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (hasBlocks) { return ( <Fragment> {blocks.map((block, index) => { const { blockName, blockType } = block
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
if (Block) {
return (
<div className="my-16" key={index}>
<Block id={blockName} {...block} />
</div>
)
}
}
return null
})}
</Fragment>
)
}
return null }
We will now ensure this is in our `page.tsx` file by getting rid of the `{JSON.stringify(page.layout)}`.
Let’s start by creating our image block. In the `blocks` directory, I’ll go to the `image` folder and create a `server.tsx` file. I’ll set up the component as an RFC and display the image using the Next.js `Image` component. Make sure to import and map this block in our `renderBlocks` utility.
Next, we’ll handle the cover block. I’ll go to the `cover` directory, create `server.tsx`, and set it up similarly. The cover block has a heading and subheading, but the heading is rich text. We’ll need to serialize this rich text into JSX using a utility function from the Payload website template. Once we’ve set up the cover block, we’ll move on to the rich text block, which we can copy from the cover block setup.
Lastly, we’ll create a block to show recent blog posts. This block will fetch the latest three blog posts and display them dynamically. We’ll set it up similarly to how we handled the header, using an async function to fetch the posts and then mapping through them to display their titles and authors.
Once all blocks are set up, we can style them using Tailwind CSS to ensure they fit the overall design of the site.
Finally, we need to create a dynamic route for individual blog posts. Inside the `website/blog` directory, create a `[slug]` folder and a `page.tsx` file. This will fetch and render each blog post dynamically based on its slug. The setup is similar to the dynamic pages, but instead of fetching pages, we’ll fetch blog posts.
Once this is done, you should be able to navigate to individual blog posts, and the content will be dynamically loaded and rendered using the blocks we’ve set up.
This wraps up our video for today. All the necessary resources can be found in the description. This guide is based on the official website template from Payload, but simplified to help you better understand the core concepts. If you have any questions, feel free to ask in the comments. See you in the next video!