Next Page Layout
A type safe, zero dependency layout solution with data fetching capabilities for Next.js.
Features
- Persisted layout state when navigating between pages *
- Server-side layout data population with the usual suspects (getInitialProps, getServerSideProps and getStaticProps)
- Client-side layout data population ("useInitialProps")
- Nested layouts with per-layout data requirements, all fetched in parallel
- A type safe API using Typescript
* Similar to layouts recently added to Nextjs.
Current status
This library is fairly new and doesn't have a large user base yet. However, I've been running this in production in several applications for quite some time already, and it's working like a charm. I suggest you give it a try and let me know how it works. I'm commited to maintaining this lib.
Background
Pages in Nextjs don't come with a hierarchy (like e.g. https://reactrouter.com/). If you want to render 2 pages with a shared layout, this layout must be rendered individually by both pages. By default this means that the shared layout will remount whenever navigating between the pages (as the top-level page component changes). This has been a recurring topic in the community and was recently addressed by Nextjs when they introduced layouts.
Nextjs layouts solve the remount issue but it's far from a perfect solution:
- Server-side data fetching (and SSR) is not supported
- There's no convenient way to duplicate server-side logic on multiple pages (
App
does not support data fetching methods) - While client-side data fetching is possible, there is no built-in mechanism to prevent a waterfall effect when rendering nested layouts with data requirements.
Getting started
- Add next-page-layout to your Nextjs project:
npm i next-page-layout
- Wrap page rendering in your custom App with
:
import { LayoutPageRenderer } from 'next-page-layout';
export default function App({ Component, pageProps }: AppProps) {
return (
);
}
- Define layouts and pages using the exported
makeLayout()
andmakeLayoutPage()
functions. - To properly support server-side rendering, instrument page rendering in your custom Document
with
prepareDocumentContext()
:
import { prepareDocumentContext } from 'next-page-layout';
export default class CustomDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
await prepareDocumentContext(ctx);
return Document.getInitialProps(ctx);
}
}
NOTE: This will cause a React hydration warning in development but it should be safe to ignore this.
The reason this happens is that when SSRing, we need to render the page twice for useParentProps()
described below to
work. The resulting HTML is passed to the client, which sees a mismatch when composing the initial component tree. This
is instantly corrected with useLayoutEffect()
which is why it should be safe to ignore this warning.
- Clone the code and run
npm run example
to see the included example Nextjs app in action.
next-page-layout was created specifically to solve these issues.
Basic Usage
Layouts are created with makeLayout()
. Consider this example:
import { makeLayout } from 'next-page-layout';
interface LayoutProps {
title: string;
children: ReactNode;
}
export const Layout = makeLayout(
{
// You can use any of:
//
// getInitialProps
// getServerSideProps
// getStaticProps
//
// But note that you cannot mix&match layouts
// with different server-side data fetching methods.
//
// See example app.
getInitialProps: async (context) => {
await sleep(300);
return {
title: 'I am a title!',
};
},
},
{
// Must be defined even for top-level layouts to make types work.
useParentProps: () => ({}),
component: (props: LayoutProps) => {
return (
);
},
}
);
Here we create (and export) a layout rendering a title using data fetched
with getInitialProps. When server-side
rendering, getInitialProps will run on the server similarly to how it would if this was a regular Nextjs page.
To render this layout as part of a page, we need to export makeLayoutPage()
in a regular Nextjs page file:
import { makeLayoutPage } from 'next-page-layout';
export default makeLayoutPage(
{
getInitialProps: async (context) => {
await sleep(300);
return {
content: 'I am a page!',
};
},
},
{
layout: Layout,
useLayoutProps: (props) => ({ title: 'Overridden title' }),
component: (props) => {
return >;
},
}
);
The page above defines its own getInitialProps but we don't have to call getInitialProps on the layout explicitly
since the library takes care of this. In useLayoutProps we have the option to pass props to our layout (such as an
overridden title). This is all type safe thanks to Typescript and type inference 👍
Nested layouts
Nested layouts are supported by passing the parent layout when calling makeLayoutPage()
:
import { makeLayout } from 'next-page-layout';
interface ChildLayoutProps {
subtitle: string;
children: ReactNode;
}
export const ChildLayout = makeLayout(
{
getInitialProps: async (context) => {
await sleep(300);
return {
subtitle: 'I am a subtitle!',
};
},
},
{
parent: Layout,
useParentProps: (props) => ({}),
component: (props: ChildLayoutProps) => {
return (
);
},
}
);
Above we create a child layout to our earlier Layout. We'd create a page with this layout just like we created the page
with the parent Layout. getInitialProps, useLayoutProps and type inference still work out of the box 👍
Client-side data fetching and useInitialProps
Sometimes we might want to do some data fetching on the client. Despite the obvious drawbacks, client-side data fetching
has the following benefits:
- Authentication info might only be available on the client, preventing the server from pre-fetching data (e.g. SSO
and/or external auth)
- The default UX when navigating between Nextjs pages can become "unresponsive" - to give a feeling of instant
navigation, it might be better to instantly render a loading indicator in the UI where content is being updated (
preserving the layout).
next-page-layout supports client-side data fetching with useInitialProps. Here's an example of Layout
and ChildLayout
used in our previous example but this time with client-side data fetching. In this example we
use SWR to fetch data, but any solution with a similar API works well (
e.g. Apollo GraphQL and useQuery).
import {
makeLayout,
makeLayoutPage,
wrapSwrInitialProps,
} from 'next-page-layout';
import useSWR from 'swr';
// Parent layout.
interface LayoutProps {
title: string;
children: ReactNode;
}
export const Layout = makeLayout(
{
useInitialProps: () => {
return wrapSwrInitialProps(
useSWR('parent', async () => {
await sleep(300);
return { title: 'I am a title!' };
})
);
},
},
{
useParentProps: (props) => ({}),
component: (props: LayoutProps) => {
return (
);
},
}
);
// Child layout.
interface ChildLayoutProps {
subtitle: string;
children: ReactNode;
}
export const ChildLayout = makeLayout(
{
useInitialProps: () => {
return wrapSwrInitialProps(
useSWR('child', async () => {
await sleep(300);
return { subtitle: 'I am a subtitle!' };
})
);
},
},
{
parent: Layout,
useParentProps: (props) => ({}),
component: (props: ChildLayoutProps) => {
return (
);
},
}
);
// Page.
export default makeLayoutPage(
{
useInitialProps: () => {
return wrapSwrInitialProps(
useSWR('page', async () => {
await sleep(300);
return { content: 'Page' };
})
);
},
},
{
layout: ChildLayout,
useLayoutProps: (props) => ({
subtitle: 'Overriden subtitle!',
}),
component: (props) => {
return >;
},
}
);
Note that while the example above renders 3 levels of components (Layout, ChildLayout and Page), all using client-side
data fetching, there's no waterfall effect. All data fetching happens in parallel! Also note that there's nothing
stopping you from mixing and matching layouts/pages with both getInitialProps and useInitialProps. 👍
useParentProps
The signature for useParentProps is slightly awkward.
type UseParentProps<
TProps extends LayoutBaseProps,
TInitialProps extends Partial,
TParent extends Layout | undefined
> = (props: {
initialProps: InitialProps;
layoutProps: InitialProps>;
requireInitialProps: (
callback: (initialProps: TInitialProps) => LayoutProps
) => any;
requireLayoutProps: (
callback: (
layoutProps: LayoutSelfProps
) => LayoutProps
) => any;
requireProps: (
callback: (props: {
initialProps: TInitialProps;
layoutProps: LayoutSelfProps;
}) => LayoutProps
) => any;
}) => LayoutProps;
useParentProps gets passed an object with various properties and is expected to return "layout props" for the parent.
The various properties on the object are:
initialProps
These are the initial props fetched with either getInitialProps or useInitialProps, wrapped in InitialProps
which is a
data loader wrapper containing the state of the loaded data.
export type InitialProps = {
data: TInitialProps | undefined;
loading?: boolean;
error?: Error;
};
layoutProps
These are the "layout props" passed to the layout by a child layout (or page). These are also wrapped in the same data
loader wrapper.
requireInitialProps, requireLayoutProps and requireProps
If we'd only support server-side data fetching with getInitialProps, the signature would be a lot simpler. In this case
we wouldn't need the data loader wrapper InitialProps
at all and could simply have useParentProps accept the resolved
initialProps and layoutProps.
However, since we support client-side data fetching, we need to account for the fact that both initialProps and
layoutProps might not be resolved when the layouts are rendered and useParentProps called. If you have a layout which
passes some property to a parent layout, and this property is based on data loaded on the client, you have 2 options:
- You pass a placeholder value if data is still being loaded OR
- You use any of the
require
functions to signal that the loading state of the parent layout is dependent on the child
layout
Here's an example of the latter, using the page in our previous example, but passing data loaded client-side to the
parent:
import { makeLayoutPage, wrapSwrInitialProps } from 'next-page-layout';
import useSWR from 'swr';
export default makeLayoutPage(
{
useInitialProps: () => {
return wrapSwrInitialProps(
useSWR('page', async () => {
await sleep(300);
return { content: 'Page' };
})
);
},
},
{
layout: ChildLayout,
useLayoutProps: (props) =>
props.requireInitialProps((initialProps) => ({
subtitle: `Overridden ,
})),
component: (props) => {
return >;
},
}
);
Support
Feel free to open an issue or reach out
to @abergenw.