29

I am using getServerSideProps in pages/post/index.js:

import React from "react";
import Layout from "../../components/Layout";

function Post({ post }) {
  console.log("in render", post);
  return (
    <Layout title={post.name}>
      <pre>{JSON.stringify(post, undefined, 2)}</pre>
    </Layout>
  );
}

export async function getServerSideProps({ query }) {
  return fetch(
    `${process.env.API_URL}/api/post?id=${query.id}`
  )
    .then(result => result.json())
    .then(post => ({ props: { post } }));
}

export default Post;

When I directly load /post/2 it works as expected but when I go from /posts to /post/2 by clicking on a link:

<Link
  as={`/post/${post.id}`}
  href={`/post?id=${post.id}`}
>

It looks like nothing happens for 2 seconds (the api delay) and then the content shows. I can see in the network tab that _next/data/development/post/9.json is being loaded by fetchNextData.

I would like to show a loading spinner when I move from one route to another using next/Link but I can't find any documentation on getServerSideProps that allows me to do this.

When I directly go to /post/:id I'd like the data to be fetched server side and get a fully rendered page (works) but when I then move to another route the data should be fetched from the client (works). However; I would like to have a loading indicator and not have the UI freeze up for the duration of the data request.

tanguy_k
  • 11,307
  • 6
  • 54
  • 58
HMR
  • 37,593
  • 24
  • 91
  • 160

8 Answers8

57

Here is an example using hooks.

pages/_app.js

    import Router from "next/router";

    export default function App({ Component, pageProps }) {
      const [loading, setLoading] = React.useState(false);
      React.useEffect(() => {
        const start = () => {
          console.log("start");
          setLoading(true);
        };
        const end = () => {
          console.log("finished");
          setLoading(false);
        };
        Router.events.on("routeChangeStart", start);
        Router.events.on("routeChangeComplete", end);
        Router.events.on("routeChangeError", end);
        return () => {
          Router.events.off("routeChangeStart", start);
          Router.events.off("routeChangeComplete", end);
          Router.events.off("routeChangeError", end);
        };
      }, []);
      return (
        <>
          {loading ? (
            <h1>Loading...</h1>
          ) : (
            <Component {...pageProps} />
          )}
        </>
      );
    }
HMR
  • 37,593
  • 24
  • 91
  • 160
  • Does it work for SSR that uses `getServerSideProps`? It doesn't work for me. – ka8725 Jul 13 '21 at 16:32
  • @ka8725 yes, it works with getServerSideProps – HMR Jul 13 '21 at 20:00
  • 2
    This doesn't work with getServerSideProps using Nextjs 12.1.5, maybe something changed. None of the console logs are shown on page load. – Stefan Apr 19 '22 at 08:21
  • 3
    Is there anyway to get this to work *only* for `getServerSideProps`? I ask because it shows "loading" for about 2 seconds for my `getServerSideProps` (looks nice), but it blinks super quickly for other routes (such as just a normal static route) which looks bad for the user. – Matthew Trent May 05 '22 at 09:50
36

You can use nprogress in your _app.js

import NProgress from 'nprogress';
import "nprogress/nprogress.css";
import Router from 'next/router';

NProgress.configure({
  minimum: 0.3,
  easing: 'ease',
  speed: 800,
  showSpinner: false,
});

Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());

or dynamic import to _app.js to reduce bundle size

ProgessBar.js

import Router from 'next/router';
import NProgress from 'nprogress';
import "nprogress/nprogress.css";

NProgress.configure({
    minimum: 0.3,
    easing: 'ease',
    speed: 500,
    showSpinner: false,
});

Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());

export default function () {
    return null;
}

_app.js

import dynamic from 'next/dynamic';
const ProgressBar = dynamic(() => import('components/atoms/ProgressBar'), { ssr: false });

const App = () => {
   ...
   return <>
       ...
       <ProgressBar />
   </>
}

Ps: If you want to change color of progress bar, you can override in global css, something like this

#nprogress .bar {
    background: #6170F7 !important;
    height: 3px !important;
}
iamhuynq
  • 5,357
  • 1
  • 13
  • 36
  • 3
    Thanks, I will try [this example](https://github.com/zeit/next.js/blob/canary/examples/with-loading/pages/_app.js). – HMR Mar 19 '20 at 10:49
  • 2
    Don't forget to import the css as well or it won't show up. `import "nprogress/nprogress.css";` – itwasmattgregg Jan 13 '21 at 21:49
4

You can create a custom hook:

usePageLoading.ts

import Router from 'next/router';
import { useEffect, useState } from 'react';

export const usePageLoading = () => {
  const [isPageLoading, setIsPageLoading] = useState(false);

  useEffect(() => {
    const routeEventStart = () => {
      setIsPageLoading(true);
    };
    const routeEventEnd = () => {
      setIsPageLoading(false);
    };

    Router.events.on('routeChangeStart', routeEventStart);
    Router.events.on('routeChangeComplete', routeEventEnd);
    Router.events.on('routeChangeError', routeEventEnd);
    return () => {
      Router.events.off('routeChangeStart', routeEventStart);
      Router.events.off('routeChangeComplete', routeEventEnd);
      Router.events.off('routeChangeError', routeEventEnd);
    };
  }, []);

  return { isPageLoading };
};

and then inside your App component use it: _app.js

import Router from "next/router";
import { usePageLoading } from './usePageLoading';

export default function App({ Component, pageProps }) {
  const { isPageLoading } = usePageLoading();

  return (
    <>
      {isPageLoading ? (
        <h1>Loading...</h1>
      ) : (
        <Component {...pageProps} />
      )}
    </>
   );
}
marko424
  • 3,839
  • 5
  • 17
  • 27
2

Just adding to the previous answers, you can receive a url parameter in the event handlers, and use those to filter out which route you want a loading state and which not. Simple example in _app.js:

function MyApp({ Component, pageProps: { ...pageProps } }: AppProps) {
    const router = useRouter();

    const [isLoading, setIsLoading] = React.useState(false);

    React.useEffect(() => {
        const handleChangeStart = (url: string) => {
            if (url === "<root_to_show_loading>") {
                setIsLoading(true);
            }
        };

        const handleChangeEnd = (url: string) => {
            if (url === "<root_to_show_loading") {
                setIsLoading(false);
            }
        };

        router.events.on("routeChangeStart", handleChangeStart);
        router.events.on("routeChangeComplete", handleChangeEnd);
        router.events.on("routeChangeError", handleChangeEnd);
    }, []);

    return (
        <main>
            {isLoading ? <LoadingSpinner /> : <Component {...pageProps} />}
        </main>
    );

}

export default MyApp;
zivnadel
  • 97
  • 6
1

How about simply adding a component level loading state to Post (vs. adding a loader on App Level for every route change since some route changes might not require server side rendering).

Setting the isLoading state to true when the relevant query param changes, in this case the post id, and setting the state to false once the props, in this case the post data, updated.

Along these lines:

pages/post/index.js:

import React from "react";
import Layout from "../../components/Layout";
import { useRouter } from 'next/router';

function Post({ post }) {
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(false);
  
  // loading new post
  useEffect(()=> {
   setIsLoading(true);
  }, [router.query?.id]);
  
  // new post loaded
  useEffect(()=> {
   setIsLoading(false)
  }, [post]);

  return (
    <>
    {isLoading ? (
      <h1>Loading...</h1>
     ) : (
      <Layout title={post.name}>
       <pre>{JSON.stringify(post, undefined, 2)}</pre>
      </Layout>
    )}
    </> 
  );
}

export async function getServerSideProps({ query }) {
  return fetch(
    `${process.env.API_URL}/api/post?id=${query.id}`
  )
    .then(result => result.json())
    .then(post => ({ props: { post } }));
}

export default Post;
Kevin K.
  • 542
  • 5
  • 7
1

Progress bar like NProgress in 90 lines of code (vs NProgress v0.2.0 is 470 lines .js + 70 lines .css):

import { useEffect, useReducer, useRef } from 'react';

import { assert } from './assert';
import { wait } from './wait';
import { getRandomInt } from './getRandomNumber';

let waitController: AbortController | undefined;

// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047
export function useProgressBar({
  trickleMaxWidth = 94,
  trickleIncrementMin = 1,
  trickleIncrementMax = 5,
  dropMinSpeed = 50,
  dropMaxSpeed = 150,
  transitionSpeed = 600
} = {}) {
  // https://stackoverflow.com/a/66436476
  const [, forceUpdate] = useReducer(x => x + 1, 0);

  // https://github.com/facebook/react/issues/14010#issuecomment-433788147
  const widthRef = useRef(0);

  function setWidth(value: number) {
    widthRef.current = value;
    forceUpdate();
  }

  async function trickle() {
    if (widthRef.current < trickleMaxWidth) {
      const inc =
        widthRef.current +
        getRandomInt(trickleIncrementMin, trickleIncrementMax); // ~3
      setWidth(inc);
      try {
        await wait(getRandomInt(dropMinSpeed, dropMaxSpeed) /* ~100 ms */, {
          signal: waitController!.signal
        });
        await trickle();
      } catch {
        // Current loop aborted: a new route has been started
      }
    }
  }

  async function start() {
    // Abort current loops if any: a new route has been started
    waitController?.abort();
    waitController = new AbortController();

    // Force the show the JSX
    setWidth(1);
    await wait(0);

    await trickle();
  }

  async function complete() {
    assert(
      waitController !== undefined,
      'Make sure start() is called before calling complete()'
    );
    setWidth(100);
    try {
      await wait(transitionSpeed, { signal: waitController.signal });
      setWidth(0);
    } catch {
      // Current loop aborted: a new route has been started
    }
  }

  function reset() {
    // Abort current loops if any
    waitController?.abort();
    setWidth(0);
  }

  useEffect(() => {
    return () => {
      // Abort current loops if any
      waitController?.abort();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return {
    start,
    complete,
    reset,
    width: widthRef.current
  };
}
import { useRouter } from 'next/router';
import { useEffect } from 'react';

import { useProgressBar } from './useProgressBar';

const transitionSpeed = 600;

// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047
export function RouterProgressBar(
  props?: Parameters<typeof useProgressBar>[0]
) {
  const { events } = useRouter();

  const { width, start, complete, reset } = useProgressBar({
    transitionSpeed,
    ...props
  });

  useEffect(() => {
    events.on('routeChangeStart', start);
    events.on('routeChangeComplete', complete);
    events.on('routeChangeError', reset); // Typical case: "Route Cancelled"

    return () => {
      events.off('routeChangeStart', start);
      events.off('routeChangeComplete', complete);
      events.off('routeChangeError', reset);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return width > 0 ? (
    // Use Bootstrap, Material UI, Tailwind CSS... to style the progress bar
    <div
      className="progress fixed-top bg-transparent rounded-0"
      style={{
        height: 3, // GitHub turbo-progress-bar height is 3px
        zIndex: 1091 // $zindex-toast + 1 => always visible
      }}
    >
      <div
        className="progress-bar"
        style={{
          width: `${width}%`,
          //transition: 'none',
          transition: `width ${width > 1 ? transitionSpeed : 0}ms ease`
        }}
      />
    </div>
  ) : null;
}

How to use:

// pages/_app.tsx

import { AppProps } from 'next/app';
import Head from 'next/head';

import { RouterProgressBar } from './RouterProgressBar';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <title>My title</title>
        <meta name="description" content="My description" />
      </Head>

      <RouterProgressBar />

      <Component {...pageProps} />
    </>
  );
}

More here: https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047

tanguy_k
  • 11,307
  • 6
  • 54
  • 58
0
**Here is how I did it in NextJs with Material UI and nprogress**

import '../styles/globals.css';
import { useEffect, useState } from 'react';
import Router from 'next/router';
import NProgress from 'nprogress';

import { useStyles } from '../src/utils';
import { CircularProgress } from '@material-ui/core';

NProgress.configure({ showSpinner: false });

function MyApp({
  Component,
  pageProps
 
}) {
  const classes = useStyles();
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const jssStyles = document.querySelector('#jss-server-side');
    if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);

    const start = () => {
      console.log('start');
      NProgress.start();
      setLoading(true);
    };
    const end = () => {
      console.log('findished');
      NProgress.done();
      setLoading(false);
    };

    Router.events.on('routeChangeStart', start);
    Router.events.on('routeChangeComplete', end);
    Router.events.on('routeChangeError', end);
    return () => {
      Router.events.off('routeChangeStart', start);
      Router.events.off('routeChangeComplete', end);
      Router.events.off('routeChangeError', end);
    };
  }, []);
  return (
    <>
       {loading ? (
          <div className={classes.centered}>
            <CircularProgress size={25} color='primary' />
          </div>
        ) : (
        <Component {...pageProps} />
       )}
    </>
  );
}

export default MyApp;

Result: enter image description here

DiaMaBo
  • 1,879
  • 1
  • 18
  • 18
0

To add to the previous answers and show complete code, you can add a delay with setTimeout when setting state in the event hook to avoid a flicker of loading on fast loading routes (either static routes, or server routes ready to go).

import Router from 'next/router';
import { useEffect, useRef, useState } from 'react';

const usePageLoad = (delay = 200) => {
  const timeoutRef = useRef();
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const start = () => {
      timeoutRef.current = window.setTimeout(() => {
        setLoading(true);
      }, delay);
    };
    const end = () => {
      window.clearTimeout(timeoutRef.current);
      setLoading(false);
    };
    Router.events.on('routeChangeStart', start);
    Router.events.on('routeChangeComplete', end);
    Router.events.on('routeChangeError', end);
    return () => {
      Router.events.off('routeChangeStart', start);
      Router.events.off('routeChangeComplete', end);
      Router.events.off('routeChangeError', end);
    };
  }, [delay]);

  return loading;
};

export default usePageLoad;

Then use this hook in _app and adjust the delay as needed for your application.

import PageLoader from '../components/PageLoader';
import usePageLoad from '../components/use-page-load';

const App = ({ Component, pageProps }) => {
  const loading = usePageLoad();

  return (
    {
       loading
          ? <PageLoader />
          : <Component {...pageProps} />
    }
  );
};
James
  • 1,100
  • 1
  • 13
  • 29