md-site-migration

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.

md-site-migration

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:

TypeScript
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

F

Flakenstine

Enchanted Experiences Co-Founder & MagicalDreams Deputy Director