Create React Next.js app with Material UI - Step by step

Create React Next.js app with Material UI - Step by step

Create React Next.js app with Material UI - Step by step
Photo by Jukan Tateisi / Unsplash

TD;DR

Instead of replicating steps below use GitHub template.

GitHub - igorrendulic/nextjs-react-typescript-material-starter-template
Contribute to igorrendulic/nextjs-react-typescript-material-starter-template development by creating an account on GitHub.

Install dependencies: yarn install

Run the server: yarn dev

Installations

  1. Install npx, yarn, update create-next-app
  2. Create next.js app
npx create-next-app my-app-name --typescript
cd my-app-name

3. Install Material UI

yarn add @mui/material @emotion/react @emotion/styled @emotion/server

4. Install Roboto font

yarn add @fontsource/roboto

5. Install Material Icons

yarn add @mui/icons-material

The Code ("src" folder)

Create utility sub-folder.
Create theme sub-folder.
Create components sub-folder

Create file createEmotionCache.ts:

import createCache from '@emotion/cache';

const createEmotionCache = () => {
    return createCache({ key: 'css', prepend: true });
};

export default createEmotionCache;
createEmotionCache.ts

Under theme create index.ts:

import type { Theme } from "@mui/material";
import {
  createTheme as createMuiTheme,
  responsiveFontSizes,
} from "@mui/material/styles";
import { baseThemeOptions } from "./base-theme-options";
import { darkThemeOptions } from "./dark-theme-options";
import { lightThemeOptions } from "./light-theme-options";

interface Neutral {
  100: string;
  200: string;
  300: string;
  400: string;
  500: string;
  600: string;
  700: string;
  800: string;
  900: string;
}

declare module "@mui/material/styles" {
  interface Palette {
    neutral?: Neutral;
  }

  interface PaletteOptions {
    neutral?: Neutral;
  }
}

interface ThemeConfig {
  responsiveFontSizes?: boolean;
  mode: "light" | "dark";
}

export const createTheme = (config: ThemeConfig): Theme => {
  let theme = createMuiTheme(
    baseThemeOptions,
    config.mode === "dark" ? darkThemeOptions : lightThemeOptions
  );

  theme = responsiveFontSizes(theme);

  return theme;
};
index.ts

Under theme sub-folder create base-theme-options.ts:

import { ThemeOptions } from '@mui/material';

export const baseThemeOptions: ThemeOptions = {
    breakpoints: {
        values: {
            xs: 0,
            sm: 600,
            md: 1000,
            lg: 1200,
            xl: 1920
        }
    },
    components: {
        MuiAvatar: {
            styleOverrides: {
                root: {
                    fontSize: 14,
                    fontWeight: 600,
                    letterSpacing: 0
                }
            }
        },
        MuiButton: {
            defaultProps: {
                disableElevation: true
            },
            styleOverrides: {
                root: {
                    textTransform: 'none',
                    borderRadius: '3px',
                },
                sizeSmall: {
                    padding: '6px 16px'
                },
                sizeMedium: {
                    padding: '8px 20px'
                },
                sizeLarge: {
                    padding: '11px 24px'
                },
                textSizeSmall: {
                    padding: '7px 12px'
                },
                textSizeMedium: {
                    padding: '9px 16px'
                },
                textSizeLarge: {
                    padding: '12px 16px'
                },
            }
        },
        MuiCardActions: {
            styleOverrides: {
                root: {
                }
            }
        },
        MuiCardContent: {
            styleOverrides: {
                root: {
                }
            }
        },
        MuiCardHeader: {
            defaultProps: {
                titleTypographyProps: {
                    variant: 'h6'
                },
                subheaderTypographyProps: {
                    variant: 'body2'
                }
            },
            styleOverrides: {
                root: {
                }
            }
        },
        MuiCheckbox: {
            defaultProps: {
                color: 'primary'
            }
        },
        MuiCssBaseline: {
            styleOverrides: {
                html: {
                    MozOsxFontSmoothing: 'grayscale',
                    WebkitFontSmoothing: 'antialiased',
                    display: 'flex',
                    flexDirection: 'column',
                    minHeight: '100%',
                    width: '100%'
                },
                body: {
                    display: 'flex',
                    flex: '1 1 auto',
                    flexDirection: 'column',
                    minHeight: '100%',
                    width: '100%'
                },
                '#nprogress': {
                    pointerEvents: 'none'
                },
                '#nprogress .bar': {
                    backgroundColor: '#000000',
                    height: 3,
                    left: 0,
                    position: 'fixed',
                    top: 0,
                    width: '100%',
                    zIndex: 2000
                }
            }
        },
        MuiPaper: {
            styleOverrides: {
                root: {
                }
            }
        },
        MuiPopover: {
            defaultProps: {
            }
        },
        MuiRadio: {
            defaultProps: {
            }
        },
        MuiSwitch: {
            defaultProps: {
            }
        },
        MuiTab: {
            styleOverrides: {
                root: {
                }
            }
        },
        MuiTableCell: {
            styleOverrides: {
                root: {
                }
            }
        },
        MuiTableHead: {
            styleOverrides: {
                root: {
                }
            }
        }
    },
    shape: {
        borderRadius: 8
    },
    typography: {
        button: {
            fontWeight: 600
        },
        fontFamily: '"Roboto+Slab:300,400,500&display=swap", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
        body1: {
            fontSize: '1.5rem',
            fontWeight: 400,
            lineHeight: 1.875,
        },
        body2: {
            fontSize: '1.275rem',
            fontWeight: 400,
            lineHeight: 1.875
        },
        subtitle1: {
            fontSize: '1rem',
            fontWeight: 500,
            lineHeight: 1.975
        },
        subtitle2: {
            fontSize: '0.975rem',
            fontWeight: 500,
            lineHeight: 1.875
        },
        overline: {
            fontSize: '0.75rem',
            fontWeight: 600,
            letterSpacing: '0.5px',
            lineHeight: 2.5,
            textTransform: 'uppercase'
        },
        caption: {
            fontSize: '0.75rem',
            fontWeight: 400,
            lineHeight: 1.66
        },
        h1: {
            fontWeight: 700,
            fontSize: '2.875rem',
            lineHeight: 1.375,
        },
        h2: {
            fontWeight: 700,
            fontSize: '2rem',
            lineHeight: 1.375,
        },
        h3: {
            fontWeight: 700,
            fontSize: '1.875rem',
            lineHeight: 1.375
        },
        h4: {
            fontWeight: 700,
            fontSize: '1.5rem',
            lineHeight: 1.375
        },
        h5: {
            fontWeight: 600,
            fontSize: '1.25rem',
            lineHeight: 1.375
        },
        h6: {
            fontWeight: 600,
            fontSize: '1.125rem',
            lineHeight: 1.375
        }
    },
};
base-theme-options.ts

Under theme sub-folder create light-theme-options.ts:

import { ThemeOptions } from '@mui/material';

// Colors

const neutral = {
    100: '#F5F5F5',
    200: '#EEEEEE',
    300: '#E0E0E0',
    400: '#BDBDBD',
    500: '#9E9E9E',
    600: '#757575',
    700: '#616161',
    800: '#424242',
    900: '#212121'
};

const background = {
    default: '#212121',
    paper: '#000',
};

const divider = 'rgba(255, 255, 255, 0.12)';

const primary = {
    main: '#fff',
    light: '#fff',
    dark: '#fff',
    contrastText: neutral[500]
};

const secondary = {
    main: '#616161',
    light: '#616161',
    dark: '#424242',
    contrastText: neutral[200]
};

const success = {
    main: '#2e7d32',
    light: '#4caf50',
    dark: '#1b5e20',
    contrastText: '#FFFFFF'
};

const info = {
    main: '#0288d1',
    light: '#03a9f4',
    dark: '#01579b',
    contrastText: '#FFFFFF'
};

const warning = {
    main: '#ed6c02',
    light: '#ff9800',
    dark: '#e65100',
    contrastText: '#FFFFFF'
};

const error = {
    main: '#d32f2f',
    light: '#ef5350',
    dark: '#c62828',
    contrastText: '#FFFFFF'
};

const text = {
    primary: '#BDBDBD',
    secondary: '#E0E0E0',
    disabled: 'rgba(55, 65, 81, 0.48)'
};


export const darkThemeOptions: ThemeOptions = {
    components: {
        MuiAvatar: {
            styleOverrides: {
                root: {
                    backgroundColor: neutral[500],
                    color: '#FFFFFF'
                }
            }
        },
        MuiInputBase: {
            styleOverrides: {
                input: {
                    '&::placeholder': {
                        opacity: 1,
                        color: text.secondary
                    }
                }
            }
        },
        MuiOutlinedInput: {
            styleOverrides: {
                notchedOutline: {
                    borderColor: divider
                }
            }
        },
        MuiMenu: {
            styleOverrides: {
                paper: {
                    borderColor: divider,
                    borderStyle: 'solid',
                    borderWidth: 1
                }
            }
        },
    },
    palette: {
        action: {
            active: neutral[400],
            hover: '#616161',
            selected: 'rgba(255, 255, 255, 0.08)',
            disabledBackground: 'rgba(255, 255, 255, 0.12)',
            disabled: 'rgba(255, 255, 255, 0.26)'
        },
        background,
        divider,
        error,
        info,
        mode: 'dark',
        neutral,
        primary,
        secondary,
        success,
        text,
        warning
    },
};
light-theme-optionts.ts

Under theme subfolder create dark-theme-options.ts:

import { ThemeOptions } from '@mui/material';

// Colors

const neutral = {
    100: '#F5F5F5',
    200: '#EEEEEE',
    300: '#E0E0E0',
    400: '#BDBDBD',
    500: '#9E9E9E',
    600: '#757575',
    700: '#616161',
    800: '#424242',
    900: '#212121'
};

const background = {
    default: '#212121',
    paper: '#000',
};

const divider = 'rgba(255, 255, 255, 0.12)';

const primary = {
    main: '#fff',
    light: '#fff',
    dark: '#fff',
    contrastText: neutral[500]
};

const secondary = {
    main: '#616161',
    light: '#616161',
    dark: '#424242',
    contrastText: neutral[200]
};

const success = {
    main: '#2e7d32',
    light: '#4caf50',
    dark: '#1b5e20',
    contrastText: '#FFFFFF'
};

const info = {
    main: '#0288d1',
    light: '#03a9f4',
    dark: '#01579b',
    contrastText: '#FFFFFF'
};

const warning = {
    main: '#ed6c02',
    light: '#ff9800',
    dark: '#e65100',
    contrastText: '#FFFFFF'
};

const error = {
    main: '#d32f2f',
    light: '#ef5350',
    dark: '#c62828',
    contrastText: '#FFFFFF'
};

const text = {
    primary: '#BDBDBD',
    secondary: '#E0E0E0',
    disabled: 'rgba(55, 65, 81, 0.48)'
};


export const darkThemeOptions: ThemeOptions = {
    components: {
        MuiAvatar: {
            styleOverrides: {
                root: {
                    backgroundColor: neutral[500],
                    color: '#FFFFFF'
                }
            }
        },
        MuiInputBase: {
            styleOverrides: {
                input: {
                    '&::placeholder': {
                        opacity: 1,
                        color: text.secondary
                    }
                }
            }
        },
        MuiOutlinedInput: {
            styleOverrides: {
                notchedOutline: {
                    borderColor: divider
                }
            }
        },
        MuiMenu: {
            styleOverrides: {
                paper: {
                    borderColor: divider,
                    borderStyle: 'solid',
                    borderWidth: 1
                }
            }
        },
    },
    palette: {
        action: {
            active: neutral[400],
            hover: '#616161',
            selected: 'rgba(255, 255, 255, 0.08)',
            disabledBackground: 'rgba(255, 255, 255, 0.12)',
            disabled: 'rgba(255, 255, 255, 0.26)'
        },
        background,
        divider,
        error,
        info,
        mode: 'dark',
        neutral,
        primary,
        secondary,
        success,
        text,
        warning
    },
};
dark-theme-options.ts

Under pages sub-folder update _document.tsx file:

import * as React from "react";
import Document, { Html, Head, Main, NextScript } from "next/document";
import createEmotionServer from "@emotion/server/create-instance";

import createEmotionCache from "../utility/createEmotionCache";

export default class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
          <link
            rel="apple-touch-icon"
            sizes="180x180"
            href="/apple-touch-icon.png"
          />
          <link rel="icon" type="image/png" href="/favicon-16x16.png" />
          <link
            rel="icon"
            type="image/png"
            sizes="32x32"
            href="/favicon-32x32.png"
          />
          <link rel="icon" type="image/png" sizes="16x16" href="/favicon.ico" />
          <meta name="theme-color" content="#000" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
  // Resolution order
  //
  // On the server:
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. document.getInitialProps
  // 4. app.render
  // 5. page.render
  // 6. document.render
  //
  // On the server with error:
  // 1. document.getInitialProps
  // 2. app.render
  // 3. page.render
  // 4. document.render
  //
  // On the client
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. app.render
  // 4. page.render

  const originalRenderPage = ctx.renderPage;

  // You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
  // However, be aware that it can have global side effects.
  const cache = createEmotionCache();
  const { extractCriticalToChunks } = createEmotionServer(cache);

  /* eslint-disable */
  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App: any) => (props) =>
        <App emotionCache={cache} {...props} />,
    });
  /* eslint-enable */

  const initialProps = await Document.getInitialProps(ctx);
  // This is important. It prevents emotion to render invalid HTML.
  // See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
  const emotionStyles = extractCriticalToChunks(initialProps.html);
  const emotionStyleTags = emotionStyles.styles.map((style) => (
    <style
      data-emotion={`${style.key} ${style.ids.join(" ")}`}
      key={style.key}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: style.css }}
    />
  ));

  return {
    ...initialProps,
    // Styles fragment is rendered after the app and page rendering finish.
    styles: [
      ...React.Children.toArray(initialProps.styles),
      ...emotionStyleTags,
    ],
  };
};
_document.tsx

Under pages sub-folder update _app.tsx:

import type { AppProps } from "next/app";

import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";

import { CacheProvider, EmotionCache } from "@emotion/react";
import createEmotionCache from "@/utility/createEmotionCache";
import { FC, useEffect } from "react";
import Head from "next/head";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { createTheme } from "@/theme";
import { ReactNode } from "react";
import { NextPage } from "next";

type MyAppProps = AppProps & {
  Component: NextPage;
  emotionCache: EmotionCache;
};

const clientSideEmotionCache = createEmotionCache();

const MyApp: FC<MyAppProps> = (props) => {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;

  const getLayout = Component.getLayout || ((page: ReactNode) => page);

  useEffect(() => {}, []);

  return (
    <CacheProvider value={emotionCache}>
      <Head>
        <meta name="viewport" content="initial-scale=1, width=device-width" />
      </Head>

      <ThemeProvider
        theme={createTheme({
          mode: "light",
        })}
      >
        <CssBaseline />
        {getLayout(<Component {...pageProps} />)}
      </ThemeProvider>
    </CacheProvider>
  );
};

export default MyApp;
_app.tsx

Under pages sub-folder update index.tsx:

import { HomePage } from "@/components/home";
import { MainLayout } from "@/components/main-layout";
import type { NextPage } from "next";
import Head from "next/head";
import {
  ReactElement,
  JSXElementConstructor,
  ReactFragment,
  ReactPortal,
} from "react";

const Home: NextPage = () => {
  return (
    <>
      <Head>
        <title>My app</title>
      </Head>
      <main>
        <HomePage />
      </main>
    </>
  );
};

Home.getLayout = (
  page:
    | string
    | number
    | boolean
    | ReactElement<any, string | JSXElementConstructor<any>>
    | ReactFragment
    | ReactPortal
    | null
    | undefined
) => <MainLayout>{page}</MainLayout>;

export default Home;
index.tsx

Under root folder create next.d.ts:

// next.d.ts

import type {
    NextComponentType,
    NextPageContext,
    NextLayoutComponentType,
} from 'next';
import type { AppProps } from 'next/app';

declare module 'next' {
    type NextPage<P = {}> = NextComponentType<
        NextPageContext,
        any,
        P
    > & {
        getLayout?: (page: ReactNode) => ReactNode;
    };
}

declare module 'next/app' {
    type AppLayoutProps<P = {}> = AppProps & {
        Component: NextLayoutComponentType;
    };
}
next.d.ts