Docs

Documentation versions (currently viewingVaadin 24)

Add a Router Layout

In this guide, you’ll learn how to create and customize router layouts in Hilla views. Router layouts are special view components that wrap around other views, providing common UI elements like navigation bars, menus, and footers.

Router Layouts in Hilla

Router layouts in Hilla are React components that wrap other views. Router layouts do not create separate navigable routes, but they wrap views that are mapped to the actual routes. Common use cases for layouts are for providing the application’s shell (e.g. including common UI elements like navigation bar, side menu, footer, etc.), or as a nested layout that wraps specific set of views with additional UI elements.

Creating a Router Layout

To create a router layout, create a file named @layout.tsx in any directory under the views. The Hilla router, by convention, wraps all the views in the respective directory and its subdirectories with that layout. The layout must render the <Outlet/> component where child views should appear.

Here’s an example of a basic router layout created directly under the views directory that wraps all views in the application, as it is located in the root of views directory:

import { Outlet } from 'react-router';

export default function MainLayout() {
    return (
        <div>
            <header>
                <h1>My Application</h1>
            </header>

            <main>
                <Outlet />
            </main>

            <footer>
                <p>© 2025 My Company</p>
            </footer>
        </div>
    );
}

In this example, the MainLayout component wraps all views in the application with a common header and footer. The <Outlet /> component acts as a placeholder of where the child views should render. Having @layout.tsx files is not limited to the root directory, you can create them in any subdirectory to create nested layouts.

Here’s an example of a layout that wraps the views defined in the customers directory and any possible subdirectories:

import { Outlet } from 'react-router';

export default function CustomersLayout() {
    return (
        <div>
            <header>
                <h1>Customers</h1>
            </header>

            <main>
                <Outlet />
            </main>
        </div>
    );
}

Depending on where a view file is located, by default it is wrapped and rendered within the available @layout.tsx of that directory, and also all the parent layouts of that directory.

For example:

views
├── customers
│   ├── {id}
│   │   ├── @index.tsx
│   │   └── edit.tsx
│   ├── @index.tsx
│   └── @layout.tsx (1)
├── @index.tsx
└── @layout.tsx (2)
  1. The layout file that wraps all views in the customers directory and its subdirectories

  2. The layout file that wraps all views in the application

Skipping Layouts

There are certain views and routes that should not be rendered inside any layouts. A login view is common example of such a view that should escape being rendered within the application layout. You can skip the layouts that are applied to views using the ViewConfig configuration object. Export this object from your view file to instruct the router not to wrap this view inside any layout available in the directory structure:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';

export default function LoginView() {
    return (
        <div>Login form here</div>
    );
}

export const config: ViewConfig = {
    skipLayouts: true, 1
};
  1. Instruct the router to skip all the layouts for this view

Creating Dynamic Menus

The Hilla router provides utilities to create navigation menus based on your route structure. Use the createMenuItems() utility to automatically generate menu items:

import { createMenuItems } from '@vaadin/hilla-file-router/runtime.js';
import { useLocation, useNavigate } from 'react-router';
import { SideNav } from '@vaadin/react-components/SideNav.js';
import { SideNavItem } from '@vaadin/react-components/SideNavItem.js';
import { Icon } from '@vaadin/react-components/Icon.js';

export default function MainMenu() {
    const navigate = useNavigate();
    const location = useLocation();

    return (
        <SideNav
            onNavigate={({path}) => path && navigate(path)}
            location={location}
        >
            {createMenuItems().map(({ to, icon, title }) => ( 1
                <SideNavItem path={to} key={to}>
                    {icon && <Icon icon={icon} slot="prefix"/>}
                    {title}
                </SideNavItem>
            ))}
        </SideNav>
    );
}
  1. Iterate over the list of available routes returned by createMenuItems() and create a menu item for each route

Note
The createMenuItems() utility returns all routes available in the application, including the routes from Flow views.

Best Practices

When working with router layouts in Hilla, follow these best practices:

  1. Use @layout.tsx naming convention for layout files

  2. Always render the <Outlet /> component where child views should appear

  3. Consider skipping layouts for authentication views

Try It

In this mini-tutorial, you’ll explore router layouts using the Vaadin walking skeleton. You’ll first explore the main layout and the automatic menu in it. Then, you’ll create a nested layout and login view that skips all the layouts.

Set Up the Project

First, generate a walking skeleton with a Hilla UI, open it in your IDE, and run it with hotswap enabled.

Explore the Main Layout

The skeleton already contains a main layout. Instead of implementing one from scratch, you’re going to have a look at it. Open src/main/frontend/views/@layout.tsx in your IDE.

The main layout is based on App Layout:

// imports and interal components

export default function MainLayout() {
  return (
    <AppLayout primarySection="drawer">
      <Header />
      <Scroller slot="drawer">
        <MainMenu />
      </Scroller>
      <UserMenu />
      <Suspense fallback={<ProgressBar indeterminate={true}
                className="m-0" />}>
        <Outlet />
      </Suspense>
    </AppLayout>
  );
}

It has a drawer on the left side with the following elements: an application header, a navigation menu, and a user menu. All the elements are styled using Lumo Utility Classes.

The Header

The header component is created by the Header() function. It contains the application’s name and logo:

function Header() {
  // TODO Replace with real application logo and name
  return (
    <div className="flex p-m gap-m items-center" slot="drawer">
      <Icon icon="vaadin:cubes" className="text-primary icon-l" />
      <span className="font-semibold text-l">Walking Skeleton</span>
    </div>
  );
}

Now, change the name and the logo. Use an icon from the default icons.

The Navigation Menu

The navigation menu component is created by the MainMenu() function. It utilizes createMenuItems includes all views — both Flow and React — that have declared a menu item:

function MainMenu() {
  const navigate = useNavigate();
  const location = useLocation();

  return (
    <SideNav className="mx-m"
             onNavigate={({ path }) => path != null && navigate(path)}
             location={location}>
      {createMenuItems().map(({ to, icon, title }) => ( 1
        <SideNavItem path={to} key={to}>
          {icon && 2
            <Icon icon={icon} slot="prefix" />} 3
          {title}
        </SideNavItem>
      ))}
    </SideNav>
  );
}
  1. createMenuItems gives access to all registered view menu items.

  2. This navigation menu assumes that all menu items have a title, but only some may have an icon. If you know all your menu items have icons, you can simplify this method.

  3. This navigation menu assumes that the icon attribute contains the name of an Icon.

The User Menu

The user menu component is created by the UserMenu() function. It is the only part of the router layout that is a stub:

function UserMenu() {
  // TODO Replace with real user information and actions
  const items = [
    {
      component: (
        <>
          <Avatar theme="xsmall"
                  name="John Smith"
                  colorIndex={5} className="mr-s" />
          John Smith
        </>
      ),
      children: [
        { text: 'View Profile', action: () => console.log('View Profile') },
        { text: 'Manage Settings', action: () => console.log('Manage Settings') },
        { text: 'Logout', action: () => console.log('Logout') },
      ],
    },
  ];
  const onItemSelected = (event: MenuBarItemSelectedEvent) => {
    const action = (event.detail.value as any).action;
    if (action) {
      action();
    }
  };
  return (
    <MenuBar theme="tertiary-inline"
             items={items}
             onItemSelected={onItemSelected}
             className="m-m" slot="drawer" />
  );
}

The Security guides show you how to add real functionality to the user menu.

Create a Nested Layout

Create a new directory named as customers under views. Inside this directory, create a new file called @layout.tsx, like this:

import { Outlet } from 'react-router';

export default function CustomersLayout() {
    return (
        <div {{ padding: '30px',
                backgroundColor: 'yellow',
                height: '100%' }}> 1
            <header>
                <h1>Customers</h1>
            </header>

            <main>
                <Outlet />  2
            </main>
        </div>
    );
}
  1. A yellow background is added to the layout to make it visually distinct from the main layout and the views that are wrapped by it.

  2. The <Outlet /> component is used to render the child views.

You can’t see what your new layout looks like yet, because you don’t have any views that use it. You’ll fix that next.

Create Example Views

You’ll now create two views that both use the new nested layout automatically. Inside the views directory, create two new views; new.tsx and @index.tsx:

export default function NewCustomerView() {
    return (
        <div style={{backgroundColor: 'red', height: '500px'}}> 1
            <header>
                <h3>Add New Customer (View)</h3>
            </header>
        </div>
    );
}
  1. A red background is added to the view to make it visually distinct from the main layout and the nested layout.

export default function CustomerListView() {
    return (
        <div style={{backgroundColor: 'cyan', height: '500px'}}> 1
            <header>
                <h3>List of Customers (View)</h3>
            </header>
        </div>
    );
}
  1. A cyan background is added to the view to make it visually distinct from the main layout and the nested layout.

Test the Application

The added views should automatically appear in the menu. If not, make sure the application is up and running, and then refresh the browser if necessary. Open your browser and navigate to: http://localhost:8080/

Either use the menu, or try navigating to the http://localhost:8080/customers manually. You should see the "List of Customers (View)" text in a cyan background rendered inside the customers' layout that has a yellow background.

Then, either use the menu, or try navigating to the http://localhost:8080/customers/new manually. You should see the "Add New Customer (View)" text in a red background rendered inside the customers' layout that has a yellow background.

Navigate back and forth between them, and verify that the nested layout is applied automatically to both views.

Add a Login View

Add a login.tsx under the views directory:

export default function LoginView() {
    return (
        <div>
            <header>
                <h1>Login View</h1>
            </header>
        </div>
    );
}

It should appear automatically in the menu. If not, make sure the application is up and running, and then refresh the browser if necessary. Open your browser and navigate to: http://localhost:8080/

Navigate to the login view using the menu or by navigating to http://localhost:8080/login. You should see the "Login View" text rendered inside the main layout. You’ll fix this next.

Skip Automatic Layout

To skip the automatic layout for the login view, you need to export a config object from the view file. Add the following code to the login.tsx file:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';

export const config: ViewConfig = {
    skipLayouts: true,
};

export default function LoginView() {
    return (
        <div>
            <header>
                <h1>Login View</h1>
            </header>
        </div>
    );
}

Now, navigate again to the login view using the menu or by navigating to http://localhost:8080/login. You should see the main layout is not applied to the login view anymore, and the "Login View" text rendered without anything wrapping it.

Optionally, move the login view to the customers directory and navigate to the http://localhost:8080/customers/login to see how the nested layout is not applied to it either.

Final Thoughts

You’ve now learned how to:

  • Create a main layout and nested layouts.

  • How to skip layouts for specific views.

Now:

  • Try adding another nested layout and views under the /views/customers/{id} directory. You can use similar steps as you did for the customers directory.

  • Verify that the nested layout is applied automatically to the views in the new directory.

  • You can also try skipping the layout for a specific view in the new directory.