Migrating from Next.js to TanStack Start (with Payload CMS, shadcn/ui, Tailwind)
I recently migrated a production site from Next.js to TanStack Start. The goal wasn’t to dunk on Next—Next.js is powerful—but I wanted a setup that felt more explicit, more “React-first,” and less tied to a specific meta-framework way of doing everything.

Introducing the new Magical Dreams website
I just finished rebuilding the Magical Dreams website—and this time it’s not just a redesign, it’s a foundation upgrade.
The old site didn’t have a blog, and I wanted a setup that would let me ship UI fast and publish content without bolting on hacks later. The result is a clean split between a modern React front-end and a real CMS backend.
Why TanStack Start?
Next.js is great, but for this project I wanted something that felt more explicit and “app-like”:
- Routing and data loading are straightforward
- It’s easy to keep UI components clean and presentational
- The TanStack ecosystem (especially Router) makes the data flow feel intentional
In practice, that meant less time fighting conventions and more time building features.
The biggest new feature: the blog (powered by Payload)
The previous Magical Dreams site didn’t have a blog at all. Now it does—and it’s backed by Payload CMS, not a folder of markdown files.
That unlocks a lot immediately:
- Drafts and scheduled publishing (depending on how you configure it)
- A proper editor experience
- Room to grow into authors, tags, featured images, SEO fields, etc.
Since Payload runs in its own Docker container, it’s also nicely separated from the front-end: content management can evolve without forcing front-end rewrites.
How the front-end talks to Payload (REST)
The pattern is simple and predictable:
- TanStack Start route loaders fetch the content they need
- A small API wrapper keeps Payload details out of the UI
- Components receive already-shaped data and just render
Example (simplified) REST fetch for a post by slug:
type PayloadPostsResponse = {
docs: Array<{
id: string
title: string
slug: string
excerpt?: string
publishedAt?: string
content?: unknown
}>
}
export async function getPostBySlug(slug: string) {
const url = new URL(
`${process.env.PAYLOAD_URL}/api/posts`
)
url.searchParams.set("where[slug][equals]", slug)
url.searchParams.set("limit", "1")
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to fetch post")
const data = (await res.json()) as PayloadPostsResponse
return data.docs[0] ?? null
}From there, the route loader calls getPostBySlug(), and the page component just renders.
UI: Tailwind + shadcn/ui = fast progress
Tailwind keeps styling consistent and fast, and shadcn/ui is still my favorite approach for shipping polished UI without taking on a heavy component library dependency.
Because the shadcn components live in your codebase, they’re easy to tweak as the site grows—no waiting on a library update to change a button style or dialog behavior.
How the migration went
Honestly: smooth.
No major pain points, no scary rewrites, and no “weekend lost to framework weirdness.” The new structure feels clean:
- routes define what data they need
- loaders fetch content
- UI components stay reusable
- Payload owns content
What’s next
Now that the base is in place, the fun part is adding polish:
- tags/categories and “related posts”
- better blog listing filters/search
- richer post layouts (code blocks, callouts, etc.)
- SEO enhancements powered by CMS fields
Written by
Flakenstine
Enchanted Experiences Co-Founder & MagicalDreams Deputy Director