Add a Router Layout
- Router Layouts in Hilla
- Creating a Router Layout
- Skipping Layouts
- Creating Dynamic Menus
- Best Practices
- Try It
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)
-
The layout file that wraps all views in the
customers
directory and its subdirectories -
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
};
-
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>
);
}
-
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:
-
Use
@layout.tsx
naming convention for layout files -
Always render the
<Outlet />
component where child views should appear -
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>
);
}
-
createMenuItems
gives access to all registered view menu items. -
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.
-
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>
);
}
-
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.
-
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>
);
}
-
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>
);
}
-
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 thecustomers
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.