From Sanity to Payload Part 2: Replicating the structure in Payload
By Alessandro Ceccarello
•July 30, 2025
•9 min read
That chef's kiss moment felt like a giant win when Hot Module Replacement (HMR) kicked in after I switched the imports in my services. Seeing the site pull data from Payload instead of Sanity made me realize the architecture work was finally paying off. It made switching CMS providers a breeze and brought us one step closer to finishing the migration.
Here's what we'll tackle in this post: architecture decisions for a Payload installation, how to create collections, how to create custom components & hooks, a brief overview on the new services and transformers, and also what's to come for our rich text display.
Now comes the fun part! Figuring out how Payload works. Since my Sanity implementation was more or less vibe-coded/AI-assisted, I wanted to use this CMS change as an opportunity to explore deeper. I took a look at all the possible admin components, multiple database adaptors, and explored solutions to the key differences between Sanity and Payload for my project needs.
Setting Up the Foundation
When I started exploring about Payload, I noticed that most of the solutions were geared towards greenfield projects. I, of course, understand that this is just the "easier" approach, but this is exactly what this series is about. I'm here to demonstrate that we can switch between CMSs by changing a few lines here and there.
I wanted to get the most barebones experience I could get when getting started so I didn't have to do a lot of heavy lifting. While I noticed their website template for creating a Payload app is filled with great practices around everything you can use in a Payload project, I wanted the most "from scratch" experience I could find. That's where the blank template comes from:
pnpx create-payload-app@latest -t blankThis gave me enough to get started. It detected that we had a Next.js project, and asked me about a database adapter. I decided to go with PostgreSQL because of familiarity, and I chose to use Supabase for simplicity, and after creating a new project, I was able to use the connection string to finish my setup.
I also explored other options like neon.tech, and I'm considering going with a Docker implementation in the future. If the needs of my site ever exceed the free tiers that these providers offer, I'll probably move to a VPS.
After all of the setup, the wizard installed the required dependencies, it created a Payload config, starter collections Next.js route groups for the /admin views, custom routes examples, and another route group for the front end.
Because I wanted to have an easier time managing both systems during the migration, I also created a separate folder for my Payload-related files, where I moved my collections and the auto-generated types. I also deleted the template's route group for the front end since we already had our fully working site with Sanity. As the final touch, I moved the config file to the root.
Note: make sure to update your tsconfig.json if you move this too.
This is the final architecture I ended up with:
.
├── public/
│ └── ...
├── src/
│ ├── app/
│ │ ├── ...
│ │ ├── (studio)/
│ │ │ └── ...
│ │ ├── (site)/
│ │ │ └── ...
│ │ └── (payload)/
│ │ └── ...
│ ├── components/
│ │ └── ...
│ ├── data/
│ │ └── ...
│ ├── payload/
│ │ ├── blocks/
│ │ │ └── ...
│ │ ├── collections/
│ │ │ └── ...
│ │ ├── components/
│ │ │ └── ...
│ │ ├── globals/
│ │ │ └── ...
│ │ ├── hooks/
│ │ │ └── ...
│ │ ├── lib/
│ │ │ └── ...
│ │ └── types.ts
│ └── sanity/
│ ├── lib/
│ │ └── ...
│ ├── queries/
│ │ └── ...
│ └── schemas/
│ └── ...
├── ...
├── next.config.ts
├── payload.config.ts
├── sanity.config.ts
└── package.jsonMedia Handling and Image Uploads
By default, Payload stores uploaded images locally, but since I'm deploying to Vercel, I needed to look into external storage options. I decided to use Vercel Blob Storage with their official adapter. I could've also used S3 buckets from Supabase, but I wanted to explore Vercel's offering and diversify my stack.
The installation was straightforward and only required installing a plugin. Here's a detailed guide if you need more info on getting started.
Now, because I wanted feature parity with Sanity, I had to make sure that my image uploads also included LQIP (low quality image placeholder). This was the perfect opportunity to explore Payload's hooks system.
I decided to use the beforeValidate hook because it allows you to add or format data before the collection is saved or updated. I used plaiceholder for generating the blur previews, and the hook code looks like this:
import type { CollectionBeforeValidateHook } from "payload";
import { Minimatch } from "minimatch";
import { getPlaiceholder } from "plaiceholder";
import { APIError } from "payload";
export const createPlaceholder: CollectionBeforeValidateHook = async ({
data,
req,
operation,
}) => {
if (operation === "create" || operation === "update") {
try {
const mimeTypePattern = "image/*";
const mimeTypeMatcher = new Minimatch(mimeTypePattern);
if (!mimeTypeMatcher.match(data?.mimeType)) {
return data;
}
const fileData = req.file?.data;
if (!Buffer.isBuffer(fileData)) {
return data;
}
const { base64 } = await getPlaiceholder(fileData, { size: 32 });
return {
...data,
placeholder: base64,
};
} catch (e) {
throw new APIError("Failed to generate blur data url");
}
}
};I also wanted to preview how the placeholder would look in the media list view. Creating this custom cell component was pretty straightforward:
import type { DefaultCellComponentProps } from "payload";
export const Cell = (props: DefaultCellComponentProps) => {
if (!props.cellData) {
return null;
}
return (
<div style={{ aspectRatio: "1 / 1", height: "60px" }}>
<img
src={props.cellData}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
backdropFilter: "blur(5px)",
transition: "filter 400ms",
filter: "blur(5px)",
}}
/>
</div>
);
};Everything comes together in the Media collection configuration:
import { type CollectionConfig } from "payload";
import { createPlaceholder } from "@/payload/hooks/Media/createPlaceholder";
export const Media: CollectionConfig = {
slug: "media",
access: {
read: () => true,
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
{
name: "placeholder",
type: "text",
admin: {
readOnly: true,
disableListFilter: true,
components: {
Cell: {
path: "@/payload/components/collections/Media/Placeholder/Cell",
exportName: "Cell",
},
},
},
},
],
hooks: {
beforeValidate: [createPlaceholder],
},
};Replicating Sanity Schemas as Payload Collections
Now that I had media storage sorted out, it was time to tackle the main event: recreating all my Sanity schemas as Payload collections. This is where the real migration work happens.
In Payload, collections are groups of records that share a common schema. Similar to how we defined the content types for Sanity, we need to make sure each schema is properly represented within our admin panel in Payload, as this will allow us to maintain a consistent structure across our content.
Because this is a small project, I only had a handful of collections to create. Here's the short list of all the schemas I created:
export default buildConfig({
// ...,
collections: [
PersonalInfo, // Author information
Users, // Used for Authentication
Media, // Uploads
TechStackCategories, // Database, FrontEnd, etc.
TechStackItems, // Next.JS, Payload, tailwindcss, etc.
Projects, // Personal projects schema
BlogPosts, // Blog posts
],
// ...
});One immediate difference I noticed was that Payload doesn't have a built-in slug field like Sanity. I created a custom component with auto-generation from the title, a lock/unlock toggle for manual editing, and a regenerate button. It uses the same speakingurl library that Sanity uses internally for consistency.
Now, with the slug component ready, I could build out my collections. Most were straightforward, but the BlogPosts collection needed some special attention:
// ... imports
export const BlogPosts: CollectionConfig = {
slug: "blog-posts",
// ...
fields: [
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
description: "URL-friendly version of the title",
components: {
Field: {
path: "@/payload/components/collections/Slug/Field",
exportName: "Field",
},
},
},
hooks: {
beforeValidate: [validateTitleSlug],
},
},
{
name: "readingTime",
type: "number",
},
// ... rest of the fields
],
hooks: {
beforeValidate: [generateReadingTime],
beforeChange: [generateSlugHook],
},
};One feature I wanted to replicate from Sanity was automatic reading time calculation. In Sanity I used a GROQ function, but in Payload this became a hook:
import { CollectionBeforeValidateHook } from "payload";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
export function calculateReadingTime(text: string) {
if (!text) return 0;
const plainText = text.replace(/<[^>]+>/g, "");
const wordCount = plainText.trim().split(/\s+/).length;
const wordsPerMinute = 200;
return Math.ceil(wordCount / wordsPerMinute);
}
export const generateReadingTime: CollectionBeforeValidateHook = async ({
data,
operation,
}) => {
if (operation === "create" || operation === "update") {
const plainText = convertLexicalToPlaintext({ data: data?.body });
const returned = {
...data,
readingTime: calculateReadingTime(plainText),
};
return returned;
}
};Payload’s rich text uses Lexical, which stores content as JSON. The convertLexicalToPlaintext utility converts this structured content into plain text suitable for calculating word count.
After doing the work for creating all the collections, I went ahead and added some data manually in the admin panel to test that everything was working as expected, compared with existing Sanity studio configurations and ensured I had all the required data in both systems.
Services and Transformers: Where the Architecture Pays Off
Now that we have some data populated in our collections, we need a way to retrieve it on our website. A quick reminder: our services are the functions we call in our page components that take care of fetching the data from our CMS and transforming it into our DTOs.
Transforming the data from Payload into a DTO works exactly the same way it would for Sanity, but instead we have Payload's specific representation of the data, we just need to make sure that the returning value has the correct values assigned in the right place.
Note: Remember that for now we can skip the Rich Text transformation so we can tackle it in the next part of the series.
Payload's TypeScript-first API simplifies our services significantly:
const slug = "my-slug";
const result = await payload.find({
collection: "blog-posts",
page,
limit,
where: { // TypeScript autocomplete!
slug: {
equals: slug,
},
},
select: {
title: true, // simple, easy to understand.
},
sort: "-publishedAt",
depth: 2, // if you have references that need to be obtained as part of the query
});To organize this, I extended the folder structure from Part 1:
src/
├── ...
└── data/
├── services/
│ ├── sanity/
│ │ └── ...
│ └── payload/
│ └── ...
├── transformers/
│ ├── sanity/
│ │ └── ...
│ └── payload/
│ └── ...
├── blog.ts
├── projects.ts
├── profile.ts
└── index.tsWe'll create the much-needed services and transformers for each of our collections. We could probably use webpack to add a path alias with environment variables, but I didn't want that complexity for such a simple change.
drumroll...
And then comes the moment we've been building toward. The import switch that makes all this architecture work pay off:
// CMS-specific services organized by provider
// when we want to change them, we just need the path to the service
- export * from "@/data/services/sanity/blog";
- export * from "@/data/services/sanity/projects";
- export * from "@/data/services/sanity/profile";
+ export * from "@/data/services/payload/blog";
+ export * from "@/data/services/payload/projects";
+ export * from "@/data/services/payload/profile";
Just this change alone allows for our data to come from a completely different data source and our website won't crash! (hopefully, lol)
We're Almost There!
Did I say the import path change was chef's kiss already? I really mean that 😃. This puts us one step closer to finishing this migration, but we have yet to talk about the elephant in the room, which is the Rich Text implementation.
In the next part of the series, we're going to talk about how to implement custom BlockFeatures in Lexical and how to keep parity between the features we had in my Sanity implementation of Rich Text.
I'm beyond excited to have our website fully functional with Payload data and working rich text components, which we'll tackle next.
Have questions about this architecture or want to share your own CMS migration experiences? Feel free to reach out. I'd love to hear how this approach works (or doesn't work) for your projects.