Creating a Next-like layout system in Vue

Lucas,developmentvueweb
Back

While learning Next and React, I had really come to like the straightforward way that layouts were able to be added to the routing system.

Instead of having the framework take care of layouts automagically, Next lets the reader perform the implementation.

Said implementation looks similar to the below example:

/**
 * layouts/default.js
 */
export default function DefaultLayout({ children }) {
  return (
    <>
      <header>
        <h1>My Website</h1>
      </header>
 
      <main>{children}</main>
      
      <footer>
        Copywrite 2022 - My Website
      </footer>
    </>
  )
}
 
/**
 * pages/index.js
 */
export default function Page() {
  return (
    <div>This is my page</div>
  )
}
 
Page.getLayout = (children) => <DefaultLayout>{children}</DefaultLayout>
 
/**
 * pages/_app.js
 */
export default function MyApp({ Component, pageProps }) {
  // Use the layout defined at the page level, if available
  const getLayout = Component.getLayout || ((page) => page)
 
  return getLayout(<Component {...pageProps} />)
}

Now while there is currently an RFC to improve the layouts situation in Next, the above is suitable for most basic sites with issues only arising as you need to track more and more state within your layouts.

So when using Vue we don't particularly have a layout system either unless you're using something like Nuxt or Vite Plugin Vue Layouts both of which abstract the problem away with some magic. Unfortunately, Nuxt doesn't have fantastic JSX/TSX support with Nuxt3 as of yet and the Vite Plugin is currently only designed to handle Single File Components (SFCs), so for a JSX/TSX user such as myself this is untenable.

To solve this issue we can take the proposed solution from Next and make it compatible with Vue, to do so we need to utilise the scoped slots available within the <RouterView /> component so we can check for a getLayout method defined on the page.

For the purposes of this article, we will assume that you are using JSX with Vue, although this is far from the norm it is my preference. If you find yourself using SFCs still, don't fear you can also still benefit from the code in this article which can be seen demonstrated at the example repository for this article.

So why do we need layouts anyway?

Using layouts while working with libraries such as React or Vue allow us to significantly reduce the amount that is occurring on a single page. We can extract simple logic and elements to the layout in addition to preparing stores or other providers for child component consumption.

This also allows us to maintain consistency over a set of pages that we have deemed to be related by ensuring that if we were to update the overall container for the pages, they would all then receive the update rather than potentially becoming inconsistent.

So why not just define the layout within the render function or template?

While we could wrap our render function or template with the layout, it is typically not preferred as it shows a tight level of coupling between the two and adds additional cognitive load to editors as they must discard the first element within a given render function or template.

Due to this we have seen a standardisation around layouts being defined as either a property or method on a component and or route.

Differing layout styles

Well then how do we add this layout system?

So to start, in the land of Vue we use Vue Router for routing. It is a first party plugin and solves all your routing needs, providing both Web History and Hash based routing. Additionally, it supports nested routes and router views.

Traditionally we would simply add a <RouterView /> component anywhere where we wanted to render a page and Vue Router would go and find the corresponding component and then render it for us.

However, Vue Router also allows us as the user to render our own content using slots where it'll pass the Component and route as a set of props to our slot content.

We can utilise this secondary method of rendering to instead check if a component has a getLayout method and then render it with the page component as an argument.

This will look like the following:

export const App = defineComponent({
  name: 'App',
 
  setup(_props, { attrs }) {
    return () => (
      <RouterView>
        {{
          default: ({ Component }) => {
            if (!Component) {
              return <div />;
            }
 
            // If the component comes with a layout then we should render that with the component
            // as a child
            if (Component.type?.getLayout && typeof Component.type.getLayout === 'function') {
              return Component.type.getLayout(h(Component, { ...attrs }));
            }
 
            // Otherwise we default to the typical <RouterView /> behaviour
            return h(Component, { ...attrs });
          },
        }}
      </RouterView>
    );
  },
});

With the signature for getLayout being the following:

{
  getLayout: (children: VNode) => VNode;
}

To keep this tidy, we recommend extracting the logic in the <App /> component into a <RouterViewWithLayout /> or <AppView /> component instead. This will also come in handy when dealing with nested <RouterView /> components if you opt to use them in your project.

So now what?

Now that we have the logic for rendering a layout when supplied via getLayout we can use it in our pages. You can see this in action in the Stackblitz Playground below.

Bonus Round: SFC Layouts

For SFCs we use a layout property which references a component rather than a getLayout method that returns VNodes. This is due to the limitations in where one can use <template> syntax. This means that while the above will still work fantastically for most needs, it still won't be as flexible as the JSX variant.

You can see the SFC version in use at the alternative playground below.

© Lucas Smith.