From Sanity to Payload Part 1: DTOs & Transformers
By Alessandro Ceccarello
•July 20, 2025
•9 min read
I was 90% done building my personal website with Sanity.io as my headless CMS when PayloadCMS announced they were joining Figma, and pure curiosity made me wonder: how hard would it be to switch CMSs mid-project?
At work, we've discussed migrating from Contentful but never pulled the trigger because of the complexity and the value it currently provides. I decided to use my personal site as a testing ground to figure out patterns that could make CMS migrations less painful.
This migration wasn't driven by any critical need. Sanity works great. But I wanted to build some muscle memory around CMS transitions before we potentially face this decision at work with much higher stakes.
In this post, I'll walk through:
- Setting up a layered architecture for CMS-agnostic data handling
- Creating DTOs that isolate UI components from CMS complexity
- Building a service layer that makes CMS migrations painless
When this might be overkill:
- Simple static sites with no CMS migration plans
- Prototypes where you're still figuring out data shapes
- Projects with a single, stable data source that won't change
The file organization
Since this is a personal site with really simple data needs, I opted for a straightforward layered architecture. I considered a few approaches:
- Domain-Driven Design: this would be overkill for just a few content types (Blog, Projects, Personal Info)
- Colocation: Keeping the data fetching logic next to the components, but this would make CMS switching harder since I'd need to update files scattered throughout the component tree.
- Layered Architecture: Separates concerns clearly and keeps CMS-specific code isolated.
My goals were clear separation of concerns, easy CMS swapping, tree-shakeable functions for potential client-side use, and import simplicity. My UI components shouldn't need to know which CMS provides the data.
Here's the folder structure I ended up with:
.
└── src/
├── app/
│ └── ...
├── components/
│ └── ...
└── data/
├── dtos/
│ ├── BlogPost.ts
│ ├── Image.ts
│ ├── PersonalInfo.ts
│ ├── Project.ts
│ └── TechStack.ts
├── services/
│ ├── sanity.io/
│ │ ├── blog.ts
│ │ ├── profile.ts
│ │ └── projects.ts
│ └── index.ts
├── transformers/
│ ├── sanity.io/
│ │ └── transformers.ts
│ └── index.ts
├── blog.ts
├── profile.ts
├── projects.ts
└── index.tsThe three core folders handle distinct responsibilities: dtos define data contracts, services handle CMS-specific fetching or any third-party API I need to connect to, and transformers normalize external data into our DTOs. Barrel files at each level mean components can import from @/data without knowing the underlying implementation.
The modular structure also means if I later need to fetch blog data client-side for features like search or filtering, I can import just getBlogPosts without pulling in unrelated logic.
Creating the Data Transfer Objects
The key insight behind this architecture came from working on a C# project at work. I saw how DTOs created a clean contract between data sources and UI components. The principle is simple: if your components only know about standardized DTO shapes, they don't care whether data comes from Sanity, Payload, or any other source.
Before using DTOs, I was managing CMS data transformations with reselect (Redux's selector library), which felt cumbersome. I had to create individual selectors for each property and write custom functions for every data shape. DTOs eliminate this complexity by establishing a single source of truth for how data should look in your application.
Let's look at a BlogPostDTO in action:
import { ImageDTO } from "./ImageDTO";
import { PersonalInfoDTO } from "./PersonalInfoDTO";
import type { PortableTextProps } from "next-sanity";
export interface BlogPostDTO {
title: string;
slug: string;
author: PersonalInfoDTO;
publishedAt: string;
updatedAt: string;
excerpt: string;
mainImage?: ImageDTO;
tags: string[];
body: PortableTextProps["value"]; // Rich text content
readingTime: number;
}
export interface BlogPostListItemDTO {
title: string;
slug: string;
author: PersonalInfoDTO;
publishedAt: string;
excerpt: string;
mainImage?: ImageDTO;
tags: string[];
readingTime: number;
updatedAt?: string;
}
export interface BlogPostMetadataDTO {
totalPosts: number;
currentPage: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}Note: I'm temporarily using Sanity's PortableText type for the body field. When I migrate to Payload, I'll update this to handle Lexical's rich text format. But that's exactly the point! This change will only happen in the DTO and transformer, not in every component that renders blog content.
Notice how I've created different interfaces for different use cases:
BlogPostDTO- Full post data for individual post pagesBlogPostListItemDTO- Lighter data for blog listing pagesBlogPostMetadataDTO- Pagination and metadata information
Here's how a component consumes this DTO:
import Link from "next/link";
import { CustomPortableText } from "@/components/common/portable-text/portable-text";
import { Image } from "@/components/common/image";
import { Button } from "@/components/ui/button";
import { BlogPostHeader } from "@/components/blog/blog-post-header";
import type { BlogPostDTO } from "@/data";
interface BlogPageProps {
post: BlogPostDTO;
}
export const BlogPage = ({ post }: BlogPageProps) => {
if (!post) {
return null;
}
return (
<main className="relative z-10 px-2 py-16 sm:py-24">
<div className="max-w-3xl mx-auto mb-6">
<Button asChild variant="ghost">
<Link href="/blog">← Back to all posts</Link>
</Button>
</div>
<div className="rounded-xl border border-border/40 bg-card/20 p-4 sm:p-6 md:p-8 max-w-max mx-auto">
<article className="max-w-3xl mx-auto">
<BlogPostHeader post={post} />
{post.mainImage && (
<div className="relative mb-8 aspect-[16/9] overflow-hidden rounded-lg border border-border/20">
<Image
src={post.mainImage.src}
alt={post.title ?? "Blog post image"}
lqip={post.mainImage.blurDataURL}
width={1000}
height={562}
/>
</div>
)}
{post.body && (
<div className="prose prose-lg dark:prose-invert">
<CustomPortableText value={post.body} />
</div>
)}
</article>
</div>
</main>
);
};The component has no idea whether this data came from Sanity, Payload, or a custom API, because it just expects a BlogPostDTO and renders accordingly. The one exception is rich text rendering: I'll need to update both the DTO's body type and replace <CustomPortableText> with a Lexical renderer when switching to Payload. This is the one area where different CMSs have fundamentally different approaches, but it's still isolated to just the rendering component rather than spreading throughout the data layer.
Normalizing the service data into our DTOs
Even with well-crafted queries, raw CMS data rarely matches what your UI needs. Here's my Sanity query and the complex type it generates:
import { defineQuery } from "next-sanity";
// Sanity query - already doing some transformation work
export const postBySlugQuery = defineQuery(`
*[_type == "blogPost" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current, // Flattening slug object
author->, // Expanding reference
publishedAt,
_updatedAt,
excerpt,
mainImage{
...,
alt,
asset->{
...,
metadata{
blurhash,
lqip // Only fetching needed metadata
}
}
},
tags,
body,
"minutesToRead": round(length(pt::text(body)) / 5 / 180) // Computed field
}
`);Even with this optimized query, Sanity returns a complex nested structure:
export type PostBySlugQueryResult = {
_id: string;
title: string | null;
slug: string | null;
author: {
_id: string;
_type: "personalInfo";
_createdAt: string;
_updatedAt: string;
_rev: string;
name?: string;
// ... deeply nested introduction array with portable text
// ... other author fields
} | null;
mainImage: {
asset: {
_id: string;
_type: "sanity.imageAsset";
// ... lots of CMS-specific metadata
metadata: {
blurhash: null;
lqip: string | null;
} | null;
} | null;
hotspot?: SanityImageHotspot;
crop?: SanityImageCrop;
// ... more image complexity
} | null;
// ... rest of the complex structure
} | null;Notice the CMS-specific metadata (_id, _type, _rev), deep nesting, and unpredictable nullability. My transformer flattens this into something UI-friendly:
export const transformBlogPost = (
sanityData: PostBySlugQueryResult | null
): BlogPostDTO | undefined => {
if (!sanityData) return undefined;
return {
title: sanityData?.title || "",
slug: sanityData?.slug || "",
author: transformPersonalInfo(sanityData?.author),
publishedAt: sanityData?.publishedAt || "",
excerpt: sanityData?.excerpt || "",
mainImage: transformImage(sanityData?.mainImage),
tags: sanityData?.tags || [],
body: sanityData?.body || [],
readingTime: sanityData?.minutesToRead || 0,
updatedAt: sanityData?._updatedAt || "",
};
};The defensive programming with fallbacks handles Sanity's unpredictable nullability. Content can be incomplete, especially during development. The nested transformers (transformPersonalInfo, transformImage) handle their own complex structures, keeping this transformer focused.
Could I have shaped the Sanity query to return exactly what my UI needs? Possibly, but then I'd be locked into Sanity's query language and data structure. When I switch to Payload, I'll create a new transformer that converts Payload's response into the same BlogPostDTO shape. The UI components won't know the difference. That's the power of this abstraction layer.
Building the Service Layer
The service layer is where everything connects. Raw CMS data becomes clean, typed DTOs that components can trust. Here's where the abstraction really pays off:
import { createClient, type QueryParams } from "next-sanity";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2025-07-02",
useCdn: true,
});
export async function sanityFetch<const QueryString extends string>({
query,
params,
revalidate = 60,
tags,
}: {
query: QueryString;
params?: QueryParams;
revalidate?: number | false;
tags?: string[];
}) {
return client.fetch(query, params, {
cache: "force-cache", // Next.js 15 doesn't cache by default anymore
next: {
revalidate: tags?.length ? false : revalidate, // ISR: regenerate every 60 seconds
tags, // For targeted cache invalidation via API endpoints
},
});
}
export const getPostBySlug = async (slug: string) => {
"use cache"; // Next.js 15's new caching directive - experimenting!
const data = await sanityFetch({
query: postBySlugQuery,
params: { slug },
tags: ["post-by-slug"],
});
if (!data) return null;
return transformBlogPost(data); // Raw Sanity response → Clean BlogPostDTO
};The data flow is straightforward:
- Page component calls
getPostBySlug("my-post") - Service queries Sanity using our optimized
GROQ - Transformer converts the complex response to a clean
BlogPostDTO - Component receives typed, predictable data that knows nothing about Sanity
Here's how a page component consumes this clean interface:
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export default async function Page({ params }: PostPageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post || !post.slug) {
notFound(); // TODO: Better error handling for CMS outages
}
return <BlogPage post={post} />;
}Reality check on error handling
Right now, if Sanity goes down, users get a 404. Not ideal for production, but I haven't built robust fallbacks yet. For a real app, I'd add retry logic, cached fallback data, or at least more informative error pages.
The barrel exports keep imports clean and hide implementation details:
// CMS-specific services organized by provider
export * from "@/data/services/sanity/blog";
export * from "@/data/services/sanity/projects";
export * from "@/data/services/sanity/profile";
export * from "@/data/dtos";
export * from "@/data/transformers";
// Domain-specific exports
export * from "@/data/blog";
export * from "@/data/projects";
export * from "@/data/profile";
// For advanced usage
export * from "@/data/services"; This means components can import everything from one place:
// Instead of: import { BlogPostDTO } from "@/data/dtos/BlogPostDTO"
// And: import { getPostBySlug } from "@/data/services/sanity/blog"
import { BlogPostDTO, getPostBySlug } from "@/data";When I switch to Payload, I'll create new services under services/payload/, update the barrel file exports, and components won't need any changes. The abstraction layer does exactly what it's supposed to do.
Scaling reality
This approach worked smoothly for my ~5 content types and took maybe an hour to implement across all components. For larger apps with dozens of content types and complex relationships, you might need more sophisticated patterns, but the core principle holds: keep CMS complexity isolated from your UI layer.
Key Takeaways
This pattern isn't just about CMS migration. It's about building resilient abstractions that keep external service complexity out of your UI layer. Whether you're switching CMSs, integrating third-party APIs, or just want better type safety, DTOs and service layers provide a foundation that scales with your application's complexity.
The time invested in setting up this architecture (about an hour for my 5 content types) pays dividends when requirements change. Your UI components stay stable while your data layer evolves.
Next Up
Replicating this exact structure with Payload and seeing just how painless this migration can be. Spoiler alert: it's pretty satisfying when the abstraction works as designed.
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.