DEV Community

Kenneth G. Franqueiro
Kenneth G. Franqueiro

Posted on • Edited on

Nuxt 3 First Impressions from a Next.js User

I'd been curious to check out Vue and Nuxt for a while. Last month, I finally had a chance to start reading up on it, and experiment with rebuilding a couple of sites that I had originally built with Next.js 12 and Preact.

From my initial experience, there are certainly a few things I really like, but there are also a few things I'm wary of.

Built-in CSS module typings

In a previous post, I explained how to generate specific typings for CSS module classes in Next.js. With Vue SFCs (both with or without Nuxt), you don't have to perform any special setup for this beyond installing the Volar extension for Visual Studio Code and setting it up in your workspace - it immediately works out-of-the-box.

A CSS module block in a Vue SFC, with a header class, including an indication of how many times it is referenced

A template block in a Vue SFC, showing the header class in use, and showing the auto-generated typings based on the CSS module block

Built-in API endpoint typings (and simpler API routes)

Something that quickly stood out to me about Nuxt, coming from Next.js, was that it uses Nitro which provides a very "make simple things easy and hard things possible" approach to API routes. Not only is it extremely easy to define API endpoints that return JSON; when you do so, you also automatically get typings for their responses when using Nuxt's data fetching APIs.

Any defineEventHandler callback that returns a value results in an API route that returns the equivalent JSON:



export default defineEventHandler((event) => {
  return { hello: "world" };
});


Enter fullscreen mode Exit fullscreen mode

When you use useFetch or $fetch within a Vue component for an API route defined in this way, you'll immediately get autocomplete and typings:

Line of code reading  raw `const hello = await useFetch(

Intellisense for the  raw `hello` endraw  variable, picking up the  raw `{ hello: string }` endraw  typing from the return value of the API route

Note: Your dev server must be running in order to generate these typings.

This is a huge developer experience boost, saving you from redundantly specifying API response types on both server and client (or dealing with any implicitly on the client).

useFetch as a replacement for getServerSideProps

It took me a moment to realize that everything in Vue SFCs will run both server- and client-side by default, much like the default-exported component in a Next.js page module. There isn't really a direct equivalent to getServerSideProps, but we can make use of API routes and Nuxt's isomorphic useFetch API for that purpose.

When useFetch runs server-side, it directly calls the underlying API function with no extra network request. Additionally, it will automatically refresh client-side when any reactive references involved in the URL change (e.g. changing query parameters based on form inputs). You can explicitly instruct useFetch to watch other reactive references as well.

The absence of a more tangible boundary a la getServerSideProps means you don't need to explicitly pass along data from server-side methods to component props. Once you store the result of a useFetch call within <script setup>, it's available for use in the component template. This might also save you some effort exporting explicit typings, since you have no need to declare typings for component props for data received from the server - the types can flow directly through.

Note: In addition to tucking server-side logic into API routes, the process object available in components includes boolean server and client values, which can be used to conditionalize component logic to run during SSR or CSR but not both.

Example: Hooking up a search API to a query parameter

Let's say you have a web application with a search form that updates a list of results within the page, and reflects the current query via a GET parameter. This use case involves some unique considerations, since the same component instance remains mounted when only URL parameters change but the route remains the same.

(Bonus: If you set your form's action to the URL path of your search route, and your input's name matches the query parameter used, the approach outlined in this example should work both with and without JavaScript enabled.)

First, you'd want to initialize two refs based on the query parameter: one for the currently active query, and another to synchronize with the form's input via v-model:



const q = ref(useRoute().query.q);
const inputQuery = ref(q.value);


Enter fullscreen mode Exit fullscreen mode

When the form is submitted, you'll want to update this value via a function hooked up to the form via @submit.prevent:



const onSubmit = () => {
  q.value = inputQuery.value;
};


Enter fullscreen mode Exit fullscreen mode

Meanwhile, to populate your results, you'll want to call your API using the currently active query:



const { data, error } = await useFetch("/api/search", {
  query: { q },
  onResponse() {
    if (process.client)
      // Sync browser URL when client-side request completes.
      // (navigateTo can't be called directly within onResponse)
      nextTick(() => {
        const query = q.value ? { q: q.value } : {};
        navigateTo({
          path: "/",
          query,
        });
      });
  },
});


Enter fullscreen mode Exit fullscreen mode

Notice that we pass the q ref - not its unwrapped value - directly to query. By doing this, Nuxt is aware that it should re-fetch any time q changes - i.e. when we submit the search form.

Finally, to keep state in sync upon browser history state changes:



onBeforeRouteUpdate(async (to) => {
  q.value = inputQuery.value = to.query.q || "";
});


Enter fullscreen mode Exit fullscreen mode

Module resolution surprises

For some reason, in each app I experimented with porting, part of Nuxt's stack seemed to disagree with lodash's module format.

In a project that uses lodash only server-side, everything worked in dev, but broke when I tried running the production server. I resolved this by adding lodash to the transpile build option in nuxt.config.ts:



export default defineNuxtConfig({
  // ...
  build: {
    transpile: ["lodash"]
  },
  // ...
});


Enter fullscreen mode Exit fullscreen mode

In another project that uses lodash both server- and client-side, I additionally ran into an issue where referencing default exports from lodash's individual modules (e.g. import uniq from "lodash/uniq") didn't seem to work, even with esModuleInterop: true included in my TypeScript compileOptions. I was able to resolve this issue by switching to lodash-es.

I'm quite puzzled by these issues, since lodash is an exceedingly common dependency, and I did not encounter similar issues with any other dependencies. I really want to think I overlooked something...

Cryptic errors

When I encountered the aforementioned lodash issues, as well as a few others that cropped up, I often found that Nuxt's errors were not terribly helpful. The stack trace would be buried within Nuxt's own lifecycle without reflecting where the error originated in my own code. Sometimes there would be no stack trace at all. Perhaps I was just unlucky with the types of errors I encountered?

Documentation runs deep

Nuxt incorporates and builds upon several libraries, which means there's a lot of ground to cover when looking for documentation:

  • Nuxt
  • Vue (for components and composables)
  • Vue router (which Nuxt's routing is based upon)
  • Nitro (for the server directory, e.g. API routes)
  • h3 (for helpers available in API routes)
  • ofetch (for data fetching APIs)

You won't necessarily need to know everything about every single piece of the puzzle, but keep in mind that sometimes particular details you're looking for may originate in one of the more-specific libraries, rather than in Nuxt's own docs. (Fortunately, they cross-link in some of the most relevant places.)

Conclusion

I was pretty excited by the developer experience benefits that I discovered and outlined in this post. That being said, I'm wary of committing to using Nuxt on larger-scale apps given how much head-scratching ensued from some of the errors and snags I encountered. There's also the matter of everything under components being available as auto-imports by default; I'd probably want to fine-tune that behavior for a larger project, lest it pollute autocomplete with a bunch of stuff that's irrelevant most of the time.

I've also more recently started experimenting with Qwik, now that I realized it also has a meta-framework in the form of Qwik City. I've already experimented with porting one of my projects to it and it seems promising; I might write up thoughts on that separately after trying it on both projects I tested with Nuxt.

Given some questionable articles I've seen lately, I feel compelled to point out that I'm not simply chasing these frameworks in the name of "ooh shiny"; I firmly believe that an existing framework being "old" is not nearly sufficient reason to bail on it. However, I've seen enough folks hinting that React has been late to the party with some useful features, and I've also been wary of the complex direction Next 13 is headed in, so I've wanted to see what benefits are unique to other frameworks. That being said, Next 12 had some great features and the best developer experience I had encountered at that point, and I imagine that React will continue to have critical mass for a while.

Top comments (0)