Skip to content

Routing & Navigation ​

File-based routing with Nuxt 3 and type-safe navigation.

Route File Structure ​

Single Page Routes ​

If a route has no child routes, use a single file:

pages/
  employees.vue              # /employees (no children)
  settings.vue               # /settings (no children)

Routes with Children ​

Only create a folder with index.vue when:

  • The route has multiple child routes, OR
  • The page has many _components that would clutter the parent folder
pages/
  employees/                 # Has children
    index.vue                # /employees
    add.vue                  # /employees/add
    [id].vue                 # /employees/123
    
  settings/                  # Has many components
    index.vue                # /settings
    _components/             # Component folder
      SettingsForm.vue
      SettingsList.vue

File Structure Rule

  • No children → employees.vue
  • Has children OR many components → employees/index.vue

See Folder Structure → for component organization patterns.

Standard Resource Routes ​

RouteFilePurpose
/employeesemployees/index.vueList all
/employees/addemployees/add.vueCreate new
/employees/123employees/[id].vueView single
/employees/123/editemployees/[id]/edit.vueEdit single

Separate Routes for Add/Edit

Even though add and edit forms are similar, always use separate routes:

  • /employees/add - Create new
  • /employees/123/edit - Edit existing

They can wrap the same component internally, but different routes allow:

  • âś… Better URL semantics
  • âś… Proper back button behavior
  • âś… Bookmarkable states
  • âś… Analytics tracking

Dynamic Routes ​

Use [param] for dynamic segments:

vue
<!-- pages/employees/[id]/index.vue -->
<script setup>
const route = useRoute()
const employeeId = route.params.id  // "123"
</script>

Accessing Route Params ​

vue
<script setup>
const route = useRoute()

// Single param
const id = route.params.id

// Multiple params
const { orgId, employeeId } = route.params

// Query params
const { status, department } = route.query
</script>

Always navigate using route names, not path strings:

typescript
// âś… Correct - using route name
const handleNext = () => {
  return navigateTo({ name: 'employees' })
}

// âś… With params
return navigateTo({ 
  name: 'employees-id', 
  params: { id: 123 } 
})

// âś… With query
return navigateTo({ 
  name: 'employees',
  query: { status: 'active' }
})

// ❌ Wrong - using path strings
return navigateTo('/employees')
return navigateTo(`/employees/${id}`)

Why Route Names?

The key benefit: Dynamic segments you don't specify stay the same from the current route.

Example: You're on /hr/123/employees and want to go to settings:

typescript
// âś… With route name - orgId stays 123 automatically
return navigateTo({ name: 'hr-orgId-settings' })
// Goes to: /hr/123/settings

// ❌ With path - you need to know and pass the current orgId
const route = useRoute()
return navigateTo(`/hr/${route.params.orgId}/settings`)

You don't need to know or pass params that aren't changing!

Other benefits:

  • âś… Refactoring - Changing URLs won't break navigation
  • âś… Autocomplete - IDE suggests available routes
  • âś… Consistency - Standard pattern across the app

Required Return/Await Pattern ​

Always return or await navigateTo - Nuxt requires this:

typescript
// âś… Correct - with return
const handleNext = () => {
  return navigateTo({ name: 'employees' })
}

// âś… Correct - with await
const handleNext = async () => {
  await navigateTo({ name: 'employees' })
  console.log('Navigation complete')
}

// ❌ Wrong - missing return/await
const handleNext = () => {
  navigateTo({ name: 'employees' })  // Missing return!
}

Why This Matters

Nuxt uses the return value to manage navigation lifecycle. Without return/await, navigation can break or behave unexpectedly.

If a Function Returns navigateTo ​

If a helper function returns navigateTo, you must also return that call:

typescript
// Helper that returns navigateTo
const goToEmployee = (id: number) => {
  return navigateTo({ name: 'employees-id', params: { id } })
}

// âś… Correct - return the result
const handleClick = () => {
  return goToEmployee(123)
}

// ❌ Wrong - missing return
const handleClick = () => {
  goToEmployee(123)  // Missing return!
}

Route Names ​

Nuxt generates route names from file paths:

File PathRoute Name
employees/index.vueemployees
employees/add.vueemployees-add
employees/[id].vueemployees-id
employees/[id]/edit.vueemployees-id-edit

Finding Route Names

Check your IDE autocomplete when typing name: or refer to the file structure to determine the route name.

Query Parameters ​

Always use object syntax with route names:

typescript
// âś… Good - object syntax with route name
return navigateTo({ 
  name: 'employees',
  query: { 
    status: 'active',
    department: 'engineering'
  } 
})
// URL: /employees?status=active&department=engineering

// ❌ Avoid - path strings
return navigateTo(`/employees?status=${status}&department=${dept}`)

Route Dialogs ​

Route dialogs are dialogs controlled by the URL - they have their own route.

Why Use Route Dialogs? ​

  • âś… Shareable URLs - Send someone a direct link to the dialog
  • âś… Browser back button - Naturally closes the dialog
  • âś… Bookmarkable - Users can bookmark the dialog state
  • âś… Parent stays visible - Background page remains rendered

Structure ​

pages/
  employees/
    index.vue                # Parent page (list)
    
    index/                   # Child routes
      index.vue              # Default (can be empty/hidden)
      add.vue                # Dialog: /employees/add
      [id]/
        edit.vue             # Dialog: /employees/123/edit
        delete.vue           # Dialog: /employees/123/delete

Parent Page Setup ​

The parent must render <NuxtPage /> for child routes:

vue
<!-- pages/employees/index.vue -->
<script setup>
const { data: employees } = useOrgApi('/api/organizations/employees', {
  lazy: true
})
</script>

<template>
  <div>
    <h1>Employees</h1>
    
    <!-- Button to open dialog - use route name -->
    <RouteDialogTrigger :to="{ name: 'employees-add' }">
      Add Employee
    </RouteDialogTrigger>
    
    <!-- Employee list -->
    <EmployeeTable :employees="employees" />
    
    <!-- Render child routes (dialogs) -->
    <NuxtPage />
  </div>
</template>

Dialog Route ​

vue
<!-- pages/employees/index/add.vue -->
<script setup>
const handleSubmit = async () => {
  await $apiFetch('/api/organizations/employees', {
    method: 'post',
    body: formData.value
  })
  
  // Navigate back to parent (closes dialog) - use route name
  return navigateTo({ name: 'employees' })
}
</script>

<template>
  <RouteDialog :close-route="{ name: 'employees' }">
    <UiDialogContent>
      <UiDialogHeader>
        <UiDialogTitle>Add Employee</UiDialogTitle>
      </UiDialogHeader>
      
      <form @submit.prevent="handleSubmit">
        <!-- Form fields -->
        <UiButton type="submit">Save</UiButton>
      </form>
    </UiDialogContent>
  </RouteDialog>
</template>

RouteDialogTrigger ​

Opens a dialog by navigating to its route:

vue
<template>
  <!-- Default button - always use route name -->
  <RouteDialogTrigger :to="{ name: 'employees-add' }">
    Add Employee
  </RouteDialogTrigger>
  
  <!-- With button props -->
  <RouteDialogTrigger 
    :to="{ name: 'employees-id-delete', params: { id: 123 } }"
    variant="destructive"
    size="sm"
  >
    Delete
  </RouteDialogTrigger>
  
  <!-- Custom element (as-child) -->
  <RouteDialogTrigger 
    :to="{ name: 'employees-id-edit', params: { id: 123 } }" 
    as-child
  >
    <UiButton variant="outline">
      <Icon name="lucide:edit" />
      Edit
    </UiButton>
  </RouteDialogTrigger>
</template>

Always Use Route Names

Never use path strings like to="/employees/add" - always use route name syntax:

vue
<!-- ❌ Wrong -->
<RouteDialogTrigger to="/employees/add">

<!-- âś… Correct -->
<RouteDialogTrigger :to="{ name: 'employees-add' }">

Accessibility

RouteDialogTrigger automatically manages focus restoration - when the dialog closes, focus returns to the trigger button.

Default Child Route ​

To prevent empty state, add a default child:

vue
<!-- pages/employees/index/index.vue -->
<template>
  <div class="hidden" />
</template>

This ensures the parent page shows when no dialog is open.

Type-Safe Navigation ​

Routes use the nuxt-typed-router package:

typescript
// Use route names for navigation
navigateTo({ name: 'employees' })
navigateTo({ name: 'employees-id', params: { id: 123 } })
navigateTo({ name: 'employees-add' })

Autocomplete

Your IDE may provide autocomplete for route names when you type name:

Common Patterns ​

Pattern 1: Navigate After Action ​

typescript
const handleDelete = async (id: number) => {
  await $apiFetch(`/api/organizations/employees/${id}`, {
    method: 'delete'
  })
  
  toast.success('Employee deleted')
  
  // Navigate back to list using route name
  return navigateTo({ name: 'employees' })
}

Pattern 2: Navigate with Query ​

typescript
const handleFilter = (status: string) => {
  return navigateTo({
    name: 'employees',
    query: { status }
  })
}

Pattern 3: Navigate with Params ​

typescript
const handleViewEmployee = (id: number) => {
  return navigateTo({
    name: 'employees-id',
    params: { id }
  })
}

Pattern 4: Conditional Navigation ​

typescript
const handleNext = async () => {
  if (!isValid.value) {
    toast.error('Please fix errors')
    return  // Don't navigate
  }
  
  return navigateTo({ name: 'next-step' })
}

Pattern 5: Replace History ​

typescript
// Replace current entry (don't add to history)
return navigateTo({ name: 'employees', replace: true })

Best Practices ​

Do's âś…

  • Always use route names, not path strings
  • Always return/await navigateTo
  • Use separate routes for add/edit
  • Use route dialogs for overlay content
  • Only create folder/index.vue when needed

Don'ts ❌

  • Don't use path strings - use route names
  • Don't forget to return navigateTo
  • Don't reuse routes for different purposes
  • Don't create unnecessary folder/index.vue structures

Related: Folder Structure → | Error Handling → | API Calls →