Nuxt Routing: Dynamic, Nested and Catch-All Routes

Thiago Nunes Batista
Written by: Thiago Nunes BatistaPublished on Last updated:
Nuxt Routing: Dynamic, Nested and Catch-All Routes

Nuxt turns the files inside the pages folder into routes. This article covers how that mapping works in Nuxt 4, including dynamic slugs, nested, optional and catch-all routes and query parameters, and the routing choices that affect SEO on a real website.

How File-Based Routing Works in Nuxt

Every .vue file inside the pages folder becomes a page, and its URL comes from the file path:

FileRoute
app/pages/index.vue/
app/pages/links.vue/links
app/pages/tools/cpf-generator.vue/tools/cpf-generator

In Nuxt 4, new projects keep application code inside an app/ directory, so pages live in app/pages/, layouts in app/layouts/ and route middleware in app/middleware/. If you migrated from Nuxt 3 and still have a top-level pages/ folder, it keeps working: Nuxt detects the old structure and does not force the new one. The examples below use the app/ paths.

This system uses Vue Router under the hood. Nuxt generates the route list from your files, so you rarely need to write a router configuration by hand.

Static Routes

A static route is a page whose URL never changes, such as an about page or a tool page:

app/pages/
├── index.vue              -> /
├── about.vue              -> /about
├── privacy-policy.vue     -> /privacy-policy
└── tools/
    └── cpf-generator.vue  -> /tools/cpf-generator

If the base URL is https://onlinestore.com, Nuxt serves https://onlinestore.com/about and https://onlinestore.com/privacy-policy from these files.

Dynamic Routes

When many URLs share the same page template, such as the posts of a blog, you create one file with the parameter name inside square brackets. Both layouts below produce the same route:

Parameter locationFileRoute
File nameapp/pages/blog/[slug].vue/blog/my-post
Directory nameapp/pages/blog/[slug]/index.vue/blog/my-post

The name inside the brackets is the key you read from route.params. A slug is the readable part of a URL that identifies a resource, usually derived from its title, like how-the-routing-system-works-in-nuxt. It is not a database id: nothing guarantees it is unique or that it never changes, so your application decides how to resolve it.

Here is a post page that loads its content from Nuxt Content using the slug:

<script setup lang="ts">
const route = useRoute();

const slug = computed(() => String(route.params.slug));

const { data: post } = await useAsyncData(
  () => `post-${slug.value}`,
  () => queryCollection("blog").path(`/blog/${slug.value}`).first()
);

if (!post.value) {
  throw createError({
    status: 404,
    statusText: "Post not found",
    fatal: true
  });
}
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
  </article>
</template>

The slug is a computed, and the useAsyncData key is a function that depends on it. When the user goes from /blog/a to /blog/b, Vue Router can keep the same page component mounted, so the code in <script setup> does not necessarily run again. With a reactive key, Nuxt fetches the new post when the slug changes. If you read route.params.slug once and store it in a plain variable, the URL changes but the page keeps showing the previous post.

A slug that does not exist also needs a 404. Without the createError check, it renders an empty page with a 200 status, and search engines see a valid but empty URL.

Validating the Parameter Before Rendering

definePageMeta accepts a validate function that decides whether the route can be rendered by that page. If it returns false and no other route matches, Nuxt responds with a 404:

<script setup lang="ts">
definePageMeta({
  validate: (route) => /^[a-z0-9-]+$/.test(String(route.params.slug))
});
</script>

validate only checks the shape of the parameter. Checking that the post exists still needs the data lookup shown above.

Nested Routes

Nested routes are for sections of an interface where a parent area stays on screen while the child content changes. An admin panel with Orders, Products and Settings is a typical case.

To create one, you need a parent page file and a folder with the same name that holds the children:

app/pages/
├── admin.vue
└── admin/
    ├── index.vue      -> /admin
    ├── orders.vue     -> /admin/orders
    ├── products.vue   -> /admin/products
    └── settings.vue   -> /admin/settings

The parent file renders the shared interface and uses <NuxtPage /> where the child page should appear:

<template>
  <div>
    <nav>
      <NuxtLink to="/admin/orders">Orders</NuxtLink>
      <NuxtLink to="/admin/products">Products</NuxtLink>
      <NuxtLink to="/admin/settings">Settings</NuxtLink>
    </nav>
    <NuxtPage />
  </div>
</template>

Navigating between the children only swaps what <NuxtPage /> renders, so the menu stays in place.

Nested Routes Are Not Layouts

Nuxt has no layout.vue file inside pages/admin. A parent page and a layout are separate features:

  • A nested parent page (admin.vue) is part of the route tree. It renders child routes through <NuxtPage />.
  • A layout lives in app/layouts/ and is a reusable shell, like a header and footer. A page picks one with definePageMeta({ layout: "dashboard" }), and the layout renders the page through its slot.

Optional and Catch-All Routes

Two more bracket patterns cover cases that a plain [slug] does not:

FileMatches
app/pages/[[slug]].vue/ and /anything
app/pages/[...slug].vueany path, including several segments

Double brackets make the segment optional. Three dots make it a catch-all that receives the remaining path. Catch-all routes are useful for documentation-style URLs with variable depth and for handling old URLs that you need to redirect.

Custom Paths and Aliases

Sometimes the public URL should not follow the file structure. definePageMeta lets you set a custom path or add an alias for a page:

<script setup lang="ts">
definePageMeta({
  path: "/shirts/:slug",
  alias: ["/t-shirts/:slug"]
});
</script>

An alias makes the same page answer on more than one URL. That creates duplicate URLs, so only use it when you really need both, and point the canonical to the one you want in the search results.

Query Parameters

Query parameters carry optional state that does not change which resource the page represents: a filter, the current page of a list or a search term. The format is ?name=value, and extra parameters are joined with &:

https://onlinestore.com/tshirts?color=blue&size=m&page=2

You read them with useRoute().query. The value of a parameter is not always a single string. It can be a string, null (for ?color with no value), an array (for ?tag=seo&tag=nuxt) or undefined when the parameter is missing. A small helper avoids bugs when you use the value in a filter or API call:

// app/utils/query.ts
export function firstQueryValue(
  value: string | null | (string | null)[] | undefined
) {
  const first = Array.isArray(value) ? value[0] : value;
  return first ?? undefined;
}
<script setup lang="ts">
const route = useRoute();

const page = computed(() => Number(firstQueryValue(route.query.page)) || 1);
const color = computed(() => firstQueryValue(route.query.color));
</script>

Nuxt auto-imports the helper from app/utils, and both values update when the query changes.

Query Strings and SEO

Every combination of filters creates a different URL, and a listing with a few filters can produce a very large number of them. For URLs that only sort or narrow the same list, set a canonical that points to the unfiltered listing:

<script setup lang="ts">
useHead({
  link: [{ rel: "canonical", href: "https://onlinestore.com/tshirts" }]
});
</script>

Paginated pages are a different case: each page of a list has its own content, so ?page=2 keeps a canonical that points to itself, not to page 1. Content that should rank on its own, like a category, is better as a path (/tshirts/blue) than as a query string.

Use NuxtLink for links the user sees. It renders a normal anchor element and navigates on the client without a full reload. For redirects and navigation triggered by code, use navigateTo:

<script setup lang="ts">
async function applyFilter() {
  await navigateTo({ path: "/tshirts", query: { color: "blue", size: "m" } });
}
</script>

<template>
  <NuxtLink to="/blog/how-the-routing-system-works-in-nuxt"
    >Read the post</NuxtLink
  >
  <button @click="applyFilter">Blue, size M</button>
</template>

When a rule applies to many routes, such as requiring a login, use route middleware in app/middleware/. Inside middleware, read the to and from arguments instead of calling useRoute(). The Nuxt documentation advises against useRoute() there because middleware has no current route yet.

Common Routing Mistakes

  • Using a layout as the parent of nested routes instead of a parent page with <NuxtPage />.
  • Reading route.params once instead of deriving data from it reactively.
  • Treating route.query values as plain strings.
  • Returning an empty page with a 200 status for a slug that does not exist.
  • Creating filter URLs without deciding which version search engines should index.

Frequently Asked Questions

What is file-based routing in Nuxt?

Nuxt creates routes from the files and folders inside the pages directory. For example, app/pages/about.vue becomes /about.

Where do pages go in Nuxt 4?

New Nuxt 4 projects use app/pages/. Projects that still have a top-level pages/ folder keep working, because Nuxt detects the old structure.

How do I create a dynamic route in Nuxt?

Name the file with a bracketed parameter, such as app/pages/blog/[slug].vue, and read the value with useRoute().params.slug. Derive anything that depends on it from a computed so it updates when the slug changes.

What is the difference between nested routes and layouts?

A nested route uses a parent page with <NuxtPage /> to render child routes. A layout lives in app/layouts/ and wraps pages with a reusable shell, selected with definePageMeta({ layout }).

How do I show a 404 for an invalid slug?

Throw createError with status: 404 when the data does not exist, or return false from validate in definePageMeta when the parameter has an invalid format.

Conclusion

Before creating files, list your public URLs: which pages are static, which need a slug and which state belongs in a query string. Then decide which filtered URLs should be indexed. For the full list of options, see the Nuxt routing documentation.