DEV Community

Cover image for TanStack Start vs Nuxt: One Framework to rule them all?
Erik Hanchett
Erik Hanchett

Posted on

TanStack Start vs Nuxt: One Framework to rule them all?

Comparison of typed routing and server logic

I love Nuxt and I really like TanStack Start. But which one is better? Or are they about the same?

And if they are about the same, does it do anything my Nuxt setup can't, and is that worth leaving Vue for React? So I decided to build the same app in both frameworks and take a look. Read on below to find out!

If you'd rather watch a video, check out the video on the same topic!

The app

In both frameworks I built a small GitHub user lookup app. You type a username, the profile gets fetched on the server, and the username lands in the URL as a ?user= query param so the result is shareable. Type ErikCH, hit enter, and the card renders. Refresh the page and it's still there. It has the same behaviour so the difference lies in the code.

Difference one: server functions vs server routes

On the Nuxt side we call a server route from useAsyncData. Server are the more idiomatic way to use Nuxt to call things on the server.

<!-- app/pages/index.vue -->
<script setup lang="ts">
import { z } from 'zod'
import type { GithubUser } from '~~/server/api/github.get'

definePageMeta({
  props: route => z.object({ user: z.string().default('') }).parse(route.query),
})

const props = defineProps<{ user: string }>()
const router = useRouter()
const input = ref(props.user)

const { data, error } = await useAsyncData(
  'github-user',
  () =>
    props.user
      ? $fetch<GithubUser>('/api/github', { query: { user: props.user } })
      : Promise.resolve(null),
  { watch: [() => props.user] },
)

function lookup() {
  router.push({ query: { user: input.value.trim() } })
}
</script>
Enter fullscreen mode Exit fullscreen mode

The props option on definePageMeta maps the query into a typed page prop and re-runs on client navigation. useAsyncData fetches when there's a username and refetches whenever it changes. The conditional that returns Promise.resolve(null) skips the request on an empty query param, (or when you first load).

The server route does the outbound call:

// server/api/github.get.ts
import { z } from 'zod'

const querySchema = z.object({
  user: z.string().trim().min(1, 'Missing ?user='),
})

export default defineEventHandler(async (event): Promise<GithubUser> => {
  const { user } = await getValidatedQuery(event, querySchema.parse)
  return $fetch<GithubUser>(
    `https://api.github.com/users/${encodeURIComponent(user)}`,
  )
})
Enter fullscreen mode Exit fullscreen mode

getValidatedQuery runs the schema and hands back a typed, validated user. A bad shape, like an array or an empty string, gets a 400 before the handler runs.

TanStack Start takes a different route. There's no separate API endpoint. You write a server function and call it directly from the loader.

// src/routes/index.tsx
import { createServerFn } from '@tanstack/react-start'

const getGithubUser = createServerFn({ method: 'GET' })
  .inputValidator((username: string) => username)
  .handler(async ({ data: username }): Promise<GithubUser> => {
    const res = await fetch(
      `https://api.github.com/users/${encodeURIComponent(username)}`,
    )
    if (!res.ok) throw new Error(`GitHub API error: ${res.status}`)
    return (await res.json()) as GithubUser
  })
Enter fullscreen mode Exit fullscreen mode

One API covers reads and writes. This same createServerFn handles a GET here, and it would handle a POST mutation the same way, called from a loader or straight from a component. The return type flows through to the caller with no manual annotation.

The route wires it up:

const searchSchema = z.object({ user: z.string().catch('') })

export const Route = createFileRoute('/')({
  validateSearch: searchSchema,
  loaderDeps: ({ search: { user } }) => ({ user }),
  loader: async ({ deps: { user } }) => {
    if (!user) return { user: null, error: null }
    return { user: await getGithubUser({ data: user }), error: null }
  },
  component: Home,
})
Enter fullscreen mode Exit fullscreen mode

validateSearch defines the query, loaderDeps narrows it to just what the loader needs, and the loader runs on navigation, a lot like script setup running when a Nuxt page loads.

Between the two, I really like TanStack and it's server functions. I don't have to worry about another end point being exposed to the internet, and it's simple to use. Though both work for a lot of different scenarios.

Server components in both

Both frameworks also let you skip the client entirely with server components, and both still label the feature experimental.

Nuxt calls them islands. You flip one flag in nuxt.config.ts:

export default defineNuxtConfig({
  experimental: {
    componentIslands: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

Then a component named with a .server.vue suffix renders only on the server. It can call useFetch directly, and no client-side JavaScript ships for it. Basically you embed the server component in a client component and that's it.

<!-- app/components/GithubUserCard.server.vue -->
<script setup lang="ts">
import type { GithubUser } from '~~/server/api/github.get'

const props = defineProps<{ user: string }>()

const { data, error } = await useFetch<GithubUser>('/api/github', {
  query: { user: props.user },
})
</script>
Enter fullscreen mode Exit fullscreen mode

The card fetches on the server and streams its HTML into the page. When ?user= changes, Nuxt refetches the island in place. The GitHub response never lands in the browser bundle, which is nice to help reduce bundle size.

TanStack Start added server components too, back in April. They're also experimental, and from the docs they look pretty different from the Next.js version people know, built around a readable-stream API. I tried it and found it more complicated than the Nuxt island, so for this app I stayed on the server function.

Of everything here, the server function is still my favorite. Server routes earn their place when something outside your app needs to call in, like a webhook, or some sort of callback. For the rest, the function felt better and cleaner.

Typed search params

This is where TanStack Start made me a little jealous.

Because validateSearch puts the query schema on the route itself, the user param is typed everywhere it shows up. Not only in the component, but in navigation and links.

// Typed against the route's search schema:
navigate({ search: (prev) => ({ ...prev, user: input.trim() }) })

<Link to="/" search={{ user: 'ErikCH' }}>Look up</Link>
Enter fullscreen mode Exit fullscreen mode

Pass a key that isn't in the schema and you get a red squiggle before you save. Delete the target on a <Link> and the editor lists the real routes, because the routes are typed as well. I could see me using this all over, and if you're using an agent to help code, this will help catch the errors even quicker.

Nuxt gets you close, with two caveats. Turn on experimental.typedPages and your <NuxtLink> routes are typed, so that half matches. With the props plus Zod pattern above, the user value reaching your component is typed and validated too. What Nuxt doesn't give you yet is the query itself typed on the link. You can type the prop, but not the ?user= on a <NuxtLink>.

If you are on the Nuxt team, does anyone know a workaround? Leave a comment? I'd love a typed-query-params story on the router in Nuxt.

What they actually share

Interesting enough, both of these frameworks share a lot. Both build on Vite, so both dev servers start are super fast. Both run on Nitro for the server layer, which adds a lot of flexibility when deploying to different places. Overall in that sense they are very similar.

One practical thing to weigh. As of this recording TanStack Start is still a Release Candidate (RC), so expect a few rough edges and bugs. Nuxt is on 4.4.x, production-hardened, with a modules ecosystem for images, i18n, SEO, and a lot that TanStack Start doesn't have yet.

Links

The verdict

So, should a Nuxt developer switch? For me, no, at least not today. I'm staying on Nuxt. I like Vue, the framework is mature, and TanStack features are nice, but not enough for me to move over (specially since it's still React).

If you're already a React developer, or starting something new in React, TanStack Start is genuinely fun and I'd reach for it. React fans will feel at home in TanStack Start, Vue fans in Nuxt. I'll keep an eye on TanStack Start and pull it out for the odd project every now and then.

One last note. I did all the research for the video and this post in Kiro, an agent that works on the IDE, CLI and Web.

If you've run both, tell me which way you'd go in the comments.

Top comments (2)

Collapse
 
erikch profile image
Erik Hanchett

What framework do you reach for?

Collapse
 
nazar-boyko profile image
Nazar Boyko

On the typed ?user= on <NuxtLink> you were wishing for: the closest thing today is a definePageMeta route validator plus your Zod query schema, so a bad shape gets caught, but you're right that the link target itself still isn't typed the way TanStack's validateSearch makes it. There's an open thread on the Nuxt side about pushing query types into the typed router, so it may not stay a gap for long. Honestly the server-function-vs-server-route framing was the most useful part of this for me, because "does anything outside my app need to call in" is a cleaner rule for that choice than I've seen anywhere else.