Strategies and patterns for per-page authentication and skeleton loaders inspired by Vercel in Next.js.
Feb 16, 2021
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.
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.
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:
In this article I'll provide flexible patterns for providing the same experience to your users.
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;
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.
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;
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.
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.
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.
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
).
Link to commit for this section.
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;
};
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
!
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.
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} />;
};
If you have any questions, ideas or improvements, hit me up at @andyasprou on Twitter! I always love a chat.
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;
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 reaching out on X.
Check this site out on Github.