Recreating Vercel's static in-app page skeleton loaders in Next.js - Andreas Asprou

Recreating Vercel's static in-app page skeleton loaders in Next.js

Strategies and patterns for per-page authentication and skeleton loaders inspired by Vercel in Next.js.

Feb 16, 2021

Introduction

In this article, I'll walk through a few patterns for achieving the same skeleton loaders experience that Vercel provides in your Next.js app. I'll also throw in some advanced Next.js per-page authentication patterns, because, you deserve it.

If you want to follow along in code, I've documented the same steps I outline in this tutorial in a Github repo.

This article aims to provide a complete understanding of the patterns I've discovered, if it's too in-depth for you, hit me up on Twitter and if I get enough requests, I'll consider creating a condensed "quick and dirty" article. Having said that, I strongly recommend getting stuck into the details and re-creating these patterns in your own Next.js project, it's wildly intoxicating.

Desired UX - Skeleton loaders in Vercel

Who doesn't love Vercel's in-app page skeleton loaders? Whilst waiting for assets to download and my user data to be fetched, I get a page full of what I'm soon to expect.

Did you notice that if you visit an authenticated URL without a session you don't see them? You get taken straight to a static login page.

Did you also notice that the skeleton loaders vary on a page by page basis? Try visiting https://vercel.com/account and https://vercel.com/dashboard/usage as a logged-in user. If you didn't notice, that's a good thing. The skeleton loaders are doing their job.

I don't know about you, but I feel the users of all products deserve this experience. I couldn't help figuring out how I could replicate this experience.

Before migrating to Next.js, one of my businesses, flick.tech was built as a plain old SPA React app. It had a generic app-shell static skeleton loader baked in its index.html file as HTML and CSS. This was sufficient for an app-shell, but to have a similar experience to that of Vercel, I had to change my approach.

Flick's old application shell skeleton loader

By embedding HTML and CSS in my React app's index.html, creating skeleton loaders for each page in the app would've taken an age. Not to mention ensuring this app-shell skeleton loader doesn't appear if the user isn't logged in. Lot's of wasted time. With activities that potentially waste time, come opportunities to create systems.

How does Vercel achieve this result?

According to Guillermo, all pages on Vercel.com are static, including the in-app pages (e.g. vercel.com/dashboard). Clearly, the content within a users' dashboard cannot be static given that it is generated on a per-user basis, so what is statically generated on a dashboard page? You guessed it. A custom skeleton loader.

In addition to statically generating a skeleton loader on each in-app page, Vercel also suspends the page from becoming visible until an initial authentication check is performed. This allows them to achieve the following:

  • An unauthenticated user that visits an authenticated page (e.g. vercel.com/dashboard) is redirected to a login page before they see the skeleton loader.
  • Initial render flashes can be prevented. This happens when the initial render displays one thing, and quickly changes to something else, reflecting new information from the state. A simple example of this is on a marketing page where you may have a "Sign up" or "Visit app" button, depending on whether the user is logged in or not.

In this article I'll provide flexible patterns for providing the same experience to your users.

What we're striving towards

The API we will implement aims to delegate the responsibility of defining the skeleton loader and auth redirect features to the page component.

To specify a custom skeleton loader for a statically generated (typed) page my-page.tsx we would simply write something similar to below.

// my-page.tsx
const MyPage: CustomPage = () => {
  // ....
};

MyPage.skeletonLoader = <Skeleton />;

export default MyPage;

As a result of the learnings from inspecting some Vercel pages, we will implement some other properties on a page that will improve the DX of authentication in Next.js. Our API will be similar to that which was introduced to Blitz.js:

// dashboard.tsx
const DashboardPage: CustomPage = () => {
  // ....
};

DashboardPage.skeletonLoader = <DashboardSkeleton />;

// Redirects to a specific page if the user *is not logged in*
DashboardPage.redirectUnAuthenticatedTo = '/login';

export default DashboardPage;
// login.tsx
const LoginPage: CustomPage = () => {
  // ....
};

// Redirects to a specific page if the user *is logged in*
LoginPage.redirectAuthenticatedTo = '/dashboard'

export default LoginPage;
// home.tsx

const HomePage: CustomPage = () => {
	// ...
}

// Prevent initial render flicker
HomePage.suppressFirstRenderFlicker = true;

export default HomePage;

A basis to work from - Authentication

Let's start with creating a basic authentication setup. This will allow us to test our skeleton loaders for pages that require an authenticated user. I'll be using next-iron-session for auth. But you can use whatever setup you require.

We will use the next-iron-session example repo as a base for this project, with some small modifications including TypeScript and other developer experience improvements. You can find the repo for this project here.

Implementing a per-page skeleton loader

We aim to delegate the responsibility of rendering a skeleton loader to a page level. We will implement this by attaching a static property likeΒ getLayoutΒ to our page components and using the value inside _app.tsx.

We will ground ourselves in the safety of TypeScript by specifying a type that describes what static attributes are available on our custom Next.js pages. This was inspired by my dear friend and TypeScript Guru Piotr, who suggested once to me in a PR that:

β€œwe should specify a type for our custom Next.js pages after I wrote getLayuot and wondered wtf is wrong."
// page.types.ts
import { NextPage } from 'next';
import { ReactNode } from 'react';

export type CustomPage<Props = {}> = NextPage<Props> = NextPage & {
  skeletonLoader?: ReactNode;
}
// dashboard.tsx
const DashboardPage: CustomPage = () => {
  // Some stuff that require an authenticated user....
};

DashboardPage.skeletonLoader = <DashboardSkeleton />; // πŸ‘ˆ NEW ✨

export default DashboardPage;

Rendering the skeleton loader

Link to commit for this section

We need to specify a way for a skeleton loader to be prerendered on a page-by-page basis, and a means to determine when the skeleton loader should be hidden (i.e. when the page's content should be rendered).

To achieve the above, we will create a component responsible for rendering the skeleton loader conditional on isLoading.

// WithSkeletonLoader.tsx

interface WithSkeletonLoaderProps {
  skeletonLoader: ReactNode | undefined;
}

/*
  This can be defined however you please, maybe you'd like to wait
  for some other data to be fetched from your backend
 */
function useIsLoading() {
  const { isLoading } = useUser()

  return isLoading
}

export function WithSkeletonLoader({ children, skeletonLoader }: PropsWithChildren<WithSkeletonLoaderProps>) {
  const isLoading = useIsLoading()

  if (isLoading && skeletonLoader) {
    return <>{skeletonLoader}</>
  }

  return <>{children}</>
}

Next, we will wrap our _app.tsx with the skeleton loader renderer, and provide the skeleton loader defined on a page level.

interface CustomAppProps extends Omit<AppProps, 'Component'> {
  Component: CustomPage
}

function MyApp({ Component, pageProps }: CustomAppProps) {
  const skeletonLoader = Component.skeletonLoader

  return (
    <SWRConfig
      value={{
        fetcher: fetchJson,
        onError: (err) => {
          console.error(err)
        }
      }}
    >
      <WithSkeletonLoader skeletonLoader={skeletonLoader}>
        <Component {...pageProps} />
      </WithSkeletonLoader>
    </SWRConfig>
  )
}

export default MyApp

Keep in mind that the content specified in each page that specifies a skeletonLoader will not have its content pre-rendered with this methodology. So you might not want to use it on pages like your marketing, login, or register pages.

Testing the setup

Now if the skeletonLoader static attribute is used on a page, you should see the skeleton loader you defined in the pre-rendered HTML created for the page. For example:

// dashboard.tsx
const DashboardPage: CustomPage = () => {
  // Some stuff that require an authenticated user....
};

DashboardPage.skeletonLoader = <div>Show me those bones.</div>

export default DashboardPage;
<!-- view-source:http://localhost:3000/dashboard -->

<!DOCTYPE html>
<html>

<!-- ... -->

<body>
	<div id="__next">
		<div>Show me those bones.</div>
	</div>
	<!-- ... -->

Yay! We can now go ahead and add a custom page-defined skeleton loader on the pages that are usually blocked by a spinner or blank page until a user's data is loading.

One thing you'll notice is that if you visit view-source:http://localhost:3000/dashboard as a user who isn't logged in (e.g. incognito mode), you'll see the skeleton loader, then are redirected to the login page based on the logic defined by useRedirectUser. Clearly, we shouldn't be shown a skeleton loader for a page that won't end up being shown. This isn't the user experience we expect. Let's take some inspiration from Vercel to fix this.

Insights from Vercel.com

How Vercel handles "auth" redirects

As mentioned above, this was heavily inspired (stolen..) from Vercel. Let's begin the robbery:

<!-- view-source:https://vercel.com/dashboard -->

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charSet="utf-8" />
	<style>
	body::before {
		content: '';
		display: block;
		position: fixed;
		width: 100%;
		height: 100%;
		top: 0;
		left: 0;
		background: var(--geist-background);
		z-index: 99999
	}
	
	.render body::before {
		display: none
	}
	</style>
	<noscript>
		<style>
		body::before {
			content: none
		}
		</style>
	</noscript>
	<script>
	if(!document.cookie || document.cookie.indexOf('authorization=') === -1) {
		location.replace('/login?next=' + encodeURIComponent(location.pathname + location.search))
	} else {
		document.documentElement.classList.add('render')
	}
	</script>
	<title>Dashboard – Vercel</title>

<!-- ...we won't steal any more :) ...yet -->

Awesome... I think the JS included in the above static HTML is what Guillermo was talking about:

I also like this pattern of hiding the page with a render class on the document until the page has determined whether it should redirect, or render. Let's snag that one too. Thanks Vercel.

How Vercel handles "unauth" redirects

More from Vercel...

<!-- view-source:https://vercel.com/login -->

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charSet="utf-8" />
	<title>Login – Vercel</title>

  <!- .... -->

	<style>
		body::before {
			content: '';
			display: block;
			position: fixed;
			width: 100%;
			height: 100%;
			top: 0;
			left: 0;
			background: var(--geist-background);
			z-index: 99999
		}
		
		.render body::before {
			display: none
		}
		</style>
		<noscript>
			<style>
			body::before {
				content: none
			}
			</style>
		</noscript>
		<script>
			if(!document.cookie || document.cookie.indexOf('authorization=') === -1) {
				document.documentElement.classList.add('render')
			}
		</script>

<!- .... -->

Similar stuff. Except for this time.. no redirect? The page will load if the user does not have an authorization cookie. I can only presume the aim here is to hide the content of the page if we have a session, which will be handled by some javascript that is loaded asynchronously. It makes sense to delegate this redirect responsibility to some code that has more context, so we can perform actions like "redirect the user to a specific part of the register flow".

We'll be implementing a pattern to redirect authenticated users from "unauthenticated " pages (e.g. /login) to a home page (e.g /dashboard).

Handling the redirect on a page-by-page basis

Link to commit for this section.

Defining a new page attribute

Now we have the logic to redirect away from authenticated pages, let's define a methodology for enabling this logic on a per-page basis.

Let's rinse our "static page attributes" pattern some more. We will specify two additional page properties that will allow us to define how we should redirect the user when visiting this page.

// dashboard.tsx
const DashboardPage: CustomPage = () => {
  // Some stuff that require an authenticated user....
}

DashboardPage.skeletonLoader = <DashboardSkeleton />
DashboardPage.redirectUnAuthenticatedTo = '/login';

export default DashboardPage;

And an un-authenticated page:

// login.tsx
const LoginPage: CustomPage = () => {
	// ...
}

LoginPage.redirectAuthenticatedTo = '/dashboard';

export default LoginPage

And extend our CustomPage

// page.types.ts

export type CustomPage = NextPage & {
  skeletonLoader?: ReactNode;
  redirectUnAuthenticatedTo?: boolean;
	redirectAuthenticatedTo?: boolean;
};

Implementing the redirect

Let's start by creating a component that redirects the user correctly using the page attributes. We will later wrap our main app with it in _app.tsx.

// components/WithAuthRedirect.tsx

const hasAuthCookie = () => {
	// The same cookie value which is used by `next-iron-session`
	// to store the sesion
  return document.cookie?.indexOf(ClientConstants.AuthCookieName) !== -1;
};

interface AppRedirectProps
  extends Pick<
    CustomPage,
    | 'redirectAuthenticatedTo'
    | 'redirectUnAuthenticatedTo'
  > {}

const handleAuthRedirect = ({
  redirectAuthenticatedTo,
  redirectUnAuthenticatedTo,
}: AppRedirectProps) => {
  if (typeof window === 'undefined') return;

  if (hasAuthCookie()) {
    if (redirectAuthenticatedTo) {
      window.location.replace(redirectAuthenticatedTo);
    }
  } else {
    if (redirectUnAuthenticatedTo) {
      const url = new URL(redirectUnAuthenticatedTo, window.location.href);
      url.searchParams.append('next', window.location.pathname);
      window.location.replace(url.toString());
    }
  }
};

export function WithAuthRedirect({
  children,
  ...props
}: React.PropsWithChildren<AppRedirectProps>) {
  handleAuthRedirect(props);

  return (
    <>
      {children}
    </>
  );
}
// _app.tsx

function App({ Component, pageProps }: CustomAppProps) {
  const skeletonLoader = Component.skeletonLoader;
  const getLayout = Component.getLayout || ((page) => page);

  const authRedirect = pick(
    Component,
    'redirectAuthenticatedTo',
    'redirectUnAuthenticatedTo',
  );

  return (
    <>
			<WithAuthRedirect {...authRedirect}>
        <WithSkeletonLoader skeletonLoader={skeletonLoader}>
          {getLayout(<Component {...pageProps} />)}
        </WithSkeletonLoader>
      </WithAuthRedirect>
    </>
  )
}

If we visit /dashboard in incognito we should be redirected to /login!

Preventing initial-render flash

Commit for this section

Now we've implemented our redirecting mechanism and skeleton loaders, we need to tackle the initial-render flash problem. Some examples include:

Being redirected from an authenticated page after seeing the skeleton loader

Parts of the UI that are hydrated based on user data change (top right button)

Using our redirect implementation, the user gets redirected correctly, but still sees the skeleton loader before we redirect. Additionally, if we visit /login page as a logged-in user, we will see the statically generated login page and then get redirected to the /dashboard page. This is clearly an undesirable user experience. We would rather not see anything if we were about to be redirected. We could handle this via SSR and redirect the user in getServerSideProps, but we would lose out on all the benefits of a static generated login page and skeleton loaders!

To get the best of both worlds, we will use a similar pattern to Vercel and our WithAuthRedirect by specifying a suppressFirstRenderFlicker static attribute that hides the page content until we decide it should be rendered:

// WithAuthRedirect.tsx

const hasAuthCookie = () => {
	// The same cookie value which is used by `next-iron-session`
	// to store the sesion
  return document.cookie?.indexOf(ClientConstants.AuthCookieName) !== -1;
};

interface AppRedirectProps
  extends Pick<
    CustomPage,
    | 'redirectAuthenticatedTo'
    | 'redirectUnAuthenticatedTo'
    | 'suppressFirstRenderFlicker' // πŸ‘ˆ NEW ✨
  > {}

const handleAuthRedirect = ({
  redirectAuthenticatedTo,
  redirectUnAuthenticatedTo,
}: AppRedirectProps) => {
  if (typeof window === 'undefined') return;

  if (hasAuthCookie()) {
    if (redirectAuthenticatedTo) {
      window.location.replace(redirectAuthenticatedTo);
    }
  } else {
    if (redirectUnAuthenticatedTo) {
      const url = new URL(redirectUnAuthenticatedTo, window.location.href);
      url.searchParams.append('next', window.location.pathname);
      window.location.replace(url.toString());
    }
  }
};

export function WithAuthRedirect({
  children,
  ...props
}: React.PropsWithChildren<AppRedirectProps>) {
  handleAuthRedirect(props);

	const noPageFlicker = // πŸ‘ˆ NEW ✨
    props.suppressFirstRenderFlicker ||
    props.redirectUnAuthenticatedTo ||
    props.redirectAuthenticatedTo;

  useEffect(() => { // πŸ‘ˆ NEW ✨
    document.documentElement.classList.add(NO_PAGE_FLICKER_CLASSNAME);
  }, []);

  return (
    <>
      {noPageFlicker && <NoPageFlicker /> /* πŸ‘ˆ NEW ✨ */} 
      {children}
    </>
  );
}
// NoPageFlicker.tsx
// The implementation is similar to that to we have seen on Vercel.com above

import Head from 'next/head';

const PRELOADER_BG = 'rgb(243, 244, 246)';

export const NO_PAGE_FLICKER_CLASSNAME = 'render';

export const hideBodyCss = `
  body::before {
    content: '';
    display: block;
    position: fixed;
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
    z-index: 99999;
    background-color: ${PRELOADER_BG};
  }
  
  .${NO_PAGE_FLICKER_CLASSNAME} body::before {
    display: none;
  }
`;

const noscriptCSS = `
  body::before {
    content: none
  }
`;

export function NoPageFlicker() {
  return (
    <Head>
      <style dangerouslySetInnerHTML={{ __html: hideBodyCss }} />
      <noscript>
        <style dangerouslySetInnerHTML={{ __html: noscriptCSS }} />
      </noscript>
    </Head>
  );
}

Ensure that you update your _app.tsx to pass on the new attribute:

// _app.tsx

import { SWRConfig } from 'swr';

// ...

function MyApp({ Component, pageProps }: CustomAppProps) {
	// ...

  const authRedirect = pick(
    Component,
    'redirectAuthenticatedTo',
    'redirectUnAuthenticatedTo',
    'suppressFirstRenderFlicker', // <-- πŸ‘ˆ NEW ✨
  );

  // ...

Now, by default, if redirectAuthenticatedTo or redirectUnAuthenticatedTo is specified, the user will not see the page until the component mounts. At the point of mounting, the use would have been redirected if necessary.

We also specified an additional flag suppressFirstRenderFlicker in our WithAuthRedirect that allows u to delay the first render visibility for pages that don't require a redirect. Our home page navigation button flash is a perfect use case for this:

// index.tsx

const HomePage: CustomPage = () => {
	// ....
};

HomePage.suppressFirstRenderFlicker = true; // πŸ‘ˆ 

export default HomePage;

Now if you visit /login with a session, you will be redirected to the /dashboard page before the login page is visible. With no session, you will see the login page as expected. This is exactly the same experience as on Vercel. Success!

Caveat: If your app takes a while to fetch the data required to hydrate the UI with relevant changes (e.g. "Sign in" β†’ "Dashboard" button in the examples above), the user will see a flash. I'll explain how to mitigate this with a slightly modified pattern in another article.

(optional) Redirecting users after login

Our current redirect implementation assumes your application will redirect the user from the login page to the URL they were expecting to go to. This expected URL is stored as a query parameter using the following snippet defined in WithAuthRedirect:

const url = new URL(redirectUnAuthenticatedTo, window.location.href);
url.searchParams.append('next', window.location.pathname);
window.location.replace(url.toString());

You can implement a "redirect after login" similar to:

// login.tsx

function useRedirectToAfterLogin() {
  const { next } = useRouter().query

  if (!next) {
    return undefined
  }

  return next as string;
}

const LoginPage: CustomPage = () => {
  const router = useRouter();
  const redirectTo = useRedirectToAfterLogin();

  const handleSuccess = () => {
    router.push(redirectTo ?? '/dashboard');
  };

  return <LoginForm onSuccess={handleSuccess} />;
};

The End, what's next?

If you have any questions, ideas or improvements, hit me up at @andyasprou on Twitter! I always love a chat.

Additional auth patterns

I'll be writing an extension to this article with some additional patterns that can be used to enrich your Next.js auth developer experience, this will include the API described below. Additionally, I'll be writing about how to implement a similar experience in Blitz.js with Suspense. Blitz's built-in auth leads to a wonderful DX when using these patterns.

// dashboard.tsx
const DashboardPage: CustomPage = () => {
  // Some stuff that require an authenticated user....
}

DashboardPage.redirect = {
	if: (user: User) => Boolean(user?.isLoggedIn),
	to: '/dashboard',
};

export default DashboardPage;

Tips on building skeleton loaders

For example, there are many benefits for embedding them into components, instead of creating independent skeleton loader components:

import React from 'react';
import useSWR from 'swr';

import { API_ROUTES } from '@/lib/constants';
import { Stats } from '@/lib/types';

import { StatCard } from './StatCard';

interface DashboardStats {
  isLoading?: boolean;
}

export function DashboardStats({ isLoading: isLoading_ }: DashboardStats) {
  const { data: stats, error } = useSWR<Stats>(API_ROUTES.GetStats);

  const hasFailed = !stats || error;
  const isLoading = (!stats && !error) || isLoading_;

  if (hasFailed && !isLoading) {
    return <div>Error: {error?.message ?? 'Stats not found.'}</div>;
  }

  return (
    <div className={`${isLoading ? 'animate-pulse' : ''}`}>
      {isLoading ? (
        <div className="h-7 w-2/6 bg-gray-300 rounded-md" />
      ) : (
        <h1 className="text-3xl font-semibold">Welcome {stats?.name}!</h1>
      )}
      <div className="grid md:grid-cols-4 sm:grid-cols-2 gap-4 mt-6">
        <StatCard
          stat={stats?.followers}
          label="Followers"
          isLoading={isLoading}
        />
        <StatCard
          stat={stats?.following}
          label="Following"
          isLoading={isLoading}
        />
        <StatCard
          stat={stats?.publicGists}
          label="Public Gists"
          isLoading={isLoading}
        />
        <StatCard
          stat={stats?.publicRepos}
          label="Public Repos"
          isLoading={isLoading}
        />
      </div>
    </div>
  );
}

Get the latest from me

If you want to hear about updates about this place (new posts, new awesome products I find etc) add your email below:

If you'd like to get in touch, consider writing an email or sending a Tweet.

Check this site out on Github.