Building a Github Repo Template Part 6: Material-UI with Next/TypeScript

In this ongoing series, I am putting together a Github Repository Template for my "Go To" front-end tech stack of Next.js, React, TypeScript etc. After the first 5 parts of the series, I have the base Next.js application, linting, and testing in place (all in TypeScript). In this post, I'm going to add the Material-UI component library.

Material-UI is, in my opinion, a very powerful and very solid component library. It is very seasoned, has great TypeScript support, works well with SSR, and uses the powerful JSS library to make CSS-in-JS simple. Material-UI's use of JSS also includes great things like auto browser vendoring. It is really easy to extend existing Material-UI components and their base set of components is excellent. Why re-invent the wheel?

Just want the Code? View the full changeset covered in this post or check out the 0.0.6 release of the repository. Also, this is heavily based on the (non-TypeScript) example for Material-UI and Next.js.

Prerequisites

If you are following along, please complete part 5 of the series as certain usages of Environment Variables and Path aliases get glossed over here.

Install Dependencies

I will need to install 3 packages from Material-UI: the core library, the styles package, and their awesome icon set (although we don't utilize their icons in this post).

npm install @material-ui/core @material-ui/icons @material-ui/styles

Also, I'll need the JSS types to play nice with TypeScript

npm install --save-dev @types/styled-jsx

Add a Button

To illustrate that Material-UI is installed, I will add a Button component to the IndexContent.tsx we created in previous posts.

// / src/screens/IndexContent.tsx

import React from 'react';
import getConfig from 'next/config';
import Button from '@material-ui/core/Button';

const { publicRuntimeConfig } = getConfig();

interface IndexProps { greeting: string }
const IndexContent: React.FC<IndexProps> = (props) => {
  const { greeting } = props;

  return (
    <div>
      <h1>
        {greeting}
        {publicRuntimeConfig.greeting_emoji}
        !
      </h1>
      <Button>A Simple Button</Button>
    </div>
  );
};

export default IndexContent;

If everything works, you should see the following. Screen Shot 2020-07-20 at 9.20.42 AM.png

Next, I'll customize the button. I will leverage Material-UI's makeStyles function to create a React hook (useStyles) that will give me the className I can add to the component.

import React from 'react';
import getConfig from 'next/config';
import Button from '@material-ui/core/Button';
import { makeStyles, Theme } from '@material-ui/core/styles';

const { publicRuntimeConfig } = getConfig();

const useStyles = makeStyles((theme: Theme) => ({
  customButton: {
    backgroundColor: 'purple',
    color: theme.palette.common.white,
  },
}));

interface IndexProps { greeting: string }
const IndexContent: React.FC<IndexProps> = (props) => {
  const { greeting } = props;
  const classes = useStyles();

  return (
    <div>
      <h1>
        {greeting}
        {publicRuntimeConfig.greeting_emoji}
        !
      </h1>
      <Button variant="contained" className={classes.customButton}>A Simple Button</Button>
    </div>
  );
};

export default IndexContent;

Note: In the makeStyles function, I am passed the Material-UI theme and can access values on it. For illustration sake, I access the color pallet for the button text color.

If you are leveraging hot model reloading, you will likely see the following:

Screen Shot 2020-07-20 at 9.44.08 AM.png However, if your button is grey, check your error console. You likely have an error like:

Screen Shot 2020-07-20 at 9.45.20 AM.png The reason for the issue is that the server side of Next.js doesn't know how to handle the styles. The CSS compiles to HTML style tags that need to be included in the output on initial load.

Updating Next.js to Render Material-UI on the Server

As illustrated above, a hard server reload (vs. HMR refresh) illustrates that Next.js needs some updating to serve the correct styles on initial render. I will do this by updating the existing pages/_document.tsx to handle this. Since Next.js only runs _document.tsx on the server, it is the appropriate place to generate the css content and include in the output so the content is styled correctly on initial load. The only real addition to _document.tsx is the getInitialProps which does the bulk of the work needed.

The new content of /pages/_document.tsx should be the following:

import React from 'react';
import Document, {
  Html, Head, Main, NextScript, DocumentContext,
} from 'next/document';
import { ServerStyleSheets } from '@material-ui/core/styles';

class MyDocument extends Document {
  render(): JSX.Element {
    return (
      <Html lang="en" dir="ltr">
        <Head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
          <style>
            {`
            body {
              background-color: ${process.env.NEXT_PUBLIC_THEME_BACKGROUND};
              color: ${process.env.NEXT_PUBLIC_THEME_FONT_COLOR};
            }
            `}
          </style>
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

MyDocument.getInitialProps = async (ctx: DocumentContext) => {
  // Render app and page and get the context of the page with collected side effects.
  const sheets = new ServerStyleSheets();
  const originalRenderPage = ctx.renderPage;

  ctx.renderPage = () => originalRenderPage({
    // eslint-disable-next-line react/jsx-props-no-spreading
    enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
  });

  const initialProps = await Document.getInitialProps(ctx);

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

export default MyDocument;

If you restart your application via npm run dev you should see the following on hard reload: Screen Shot 2020-07-20 at 9.44.08 AM.png

Customizing Global Theme

Now that I have a basic implementation of Material-UI working with Next.js, I will leverage one of the more powerful features of Material-UI: Theming. For illustration purposes, I'll customize the theme so button text is lowercase by default.

To get started, let's create a custom theme in ~/theme (remember, in part 4 of the tutorial I created a path alias for /src/):

// /src/theme/index.ts 

// Define Theme to use for Material-UI
import { createMuiTheme, Theme } from '@material-ui/core/styles';
const muiTheme: Theme = createMuiTheme({
  typography: {
    button: {
      textTransform: 'none',
    },
  },
});
export default muiTheme;

Next I need to consume the theme. Material-UI does this by implementing a Theme Provider component. Any component below the Theme Provider component will have access to the them and styling. For my usage, I need to wrap the entire Next.js application. For this I'll introduce another Next.js access point _app.tsx. Like, _document.tsx, the _app.tsx is an optional integration point that Next.js allows to further extend behavior. Unlike _document.tsx, _app.tsx runs on both the server and the client. This is an optimal location to refresh the style output between route changes, hot reloads, etc.

Create a new file _app.tsx file in your /pages directory with the following content:

// /pages/_app.tsx

/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import Head from 'next/head';
import { AppProps } from 'next/app';

import { ThemeProvider } from '@material-ui/core/styles';
import CssBaseline from '@material-ui/core/CssBaseline';
import theme from '~/theme';

export default function MyApp(props: AppProps): JSX.Element {
  const { Component, pageProps } = props;

  React.useEffect(() => {
    // Remove the server-side injected CSS.
    const jssStyles = document.querySelector('#jss-server-side');
    if (jssStyles && jssStyles.parentElement) {
      jssStyles.parentElement.removeChild(jssStyles);
    }
  }, []);

  return (
    <>
      <Head>
        <title>My page</title>
        <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
      </Head>

      <ThemeProvider theme={theme}>
        {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
        <CssBaseline />
        <Component {...pageProps} />
      </ThemeProvider>
    </>
  );
}

If everything works, you should see the following when you restart the application: Screen Shot 2020-07-20 at 10.53.33 AM.png If this is the case, then I am all set to utilize Material-UI with my Next.js application. This could be a good stopping point, but I'm going to add a couple additional things to clean up the Github Repo Template.

Applying Global Styles to the Theme

In the part 5 of the series, I introduced some environment variables to control the background color and font color of the page. In this part of the tutorial, I'll move those into the Material-UI theme. This will help keep all styles in a centralized pattern.

Let's remove the style tags from the _document.tsx. The new main MyDocument class should look like:

class MyDocument extends Document {
  render(): JSX.Element {
    return (
      <Html lang="en" dir="ltr">
        <Head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Now, update the custom theme to leverage these values:

// /src/theme/index.ts

import { createMuiTheme, Theme } from '@material-ui/core/styles';

const muiTheme: Theme = createMuiTheme({
  typography: {
    button: {
      textTransform: 'none',
    },
  },
  overrides: {
    MuiCssBaseline: {
      '@global': {
        body: {
          backgroundColor: process.env.NEXT_PUBLIC_THEME_BACKGROUND,
          color: process.env.NEXT_PUBLIC_THEME_FONT_COLOR,
        },
      },
    },
  },
});
export default muiTheme;

If you reload, the styles will be applied to the body. If you inspect the body, you will see that JSS has merged the globals into the defaults as opposed to creating a whole new declaration. This is handy as it results in a smaller overall bundle size.

Screen Shot 2020-07-20 at 12.14.36 PM.png

At this point, I have Material-UI properly working with Next.js. I can customize individual components as well as change them globally through the Theme. I can also leverage the Theme to control the Global CSS stylings.

Bonus: Custom Theme Variables with TypeScript

As a final bonus feature I use a lot. I am going to add custom variables to the Theme that I can access inside of the makeStyles callback. I often use this to define Drawer widths for various breakpoints that I can then leverage for positioning other elements. Another case, is to define primary and secondary font faces and then have access to them. I'll do that here.

Material-UI by default uses the Roboto font everywhere. However, I like to make certain components use an accent font. Setting the font face everywhere you need it can be cumbersome, especially if you need to change it. As such, I can use a theme variable to define the font family. Just so it is painfully obvious when it works, I will use the Google Font Permanent Marker.

Load the font by adding the following to the <Head> tag in _document.tsx

<link href="https://fonts.googleapis.com/css2?family=Permanent+Marker&display=swap" rel="stylesheet" />

Then update the Theme to include the accentFontFamily variable:

// /src/theme/index.ts
const muiTheme: Theme = createMuiTheme({
  // Custom Variables
  accentFontFamily: "'Permanent Marker', cursive",
  ...

If I were using vanilla JavaScript, this is all I would need to do. However, I am using TypeScript and the TS compiler doesn't like that I am trying to add new values to Material-UIs theme.

Screen Shot 2020-07-20 at 12.42.02 PM.png

To make TypeScript happy, I need to extend the Theme and ThemeOption types provided by Material-UI. This is as easy as declaring the module. I'll do this at the top of the /src/theme/index.ts

declare module '@material-ui/core/styles/createMuiTheme' {
    interface Theme {
        accentFontFamily: string
    }
    interface ThemeOptions {
        accentFontFamily: string
    }
}

The full theme should look like this now:

// Define Theme to use for Material-UI

import { createMuiTheme, Theme } from '@material-ui/core/styles';

declare module '@material-ui/core/styles/createMuiTheme' {
    interface Theme {
        accentFontFamily: string
    }
    interface ThemeOptions {
        accentFontFamily: string
    }
}

const muiTheme: Theme = createMuiTheme({
  // Custom Variables
  accentFontFamily: "'Permanent Marker', cursive",

  // MUI Typography Overrides
  typography: {
    button: {
      textTransform: 'none',
    },
  },
  overrides: {
    MuiCssBaseline: {
      '@global': {
        body: {
          backgroundColor: process.env.NEXT_PUBLIC_THEME_BACKGROUND,
          color: process.env.NEXT_PUBLIC_THEME_FONT_COLOR,
        },
      },
    },
  },
});
export default muiTheme;

Finally, I will update Button from earlier to utilize the new variable.

const useStyles = makeStyles((theme: Theme) => ({
  customButton: {
    backgroundColor: '#6f2da8',
    color: theme.palette.common.white,
    fontFamily: theme.accentFontFamily,
  },
}));

Notice, I get the benefits of auto complete in the IDE when I augment the Theme.

Screen Shot 2020-07-20 at 12.53.14 PM.png

Finally, if you reload the app, you should see the fontFace applied to the button.

Screen Shot 2020-07-20 at 12.49.11 PM.png

Closing Thoughts

At this point, I have have a pretty solid Github Repository Template for my usage. In the next part in the series, I will add basic PWA support. To view the full changeset covered in this post or check out the 0.0.6 release of the repository.

Discussion Topics

  • Have you used both Styled Components and JSS? Which do you prefer and why?

Image Credit: Several Bunches of Grapes by Luiz M. Santos from Pexels

Comments (2)

Edidiong Asikpo's photo

What are the major differences between TypeScript and JavaScript?

Blaine Garrett's photo

Software Engineer and Artist

By nature, JavaScript is a loosely typed language similar to vanilla Python, etc. Whereas TypeScript is a (mostly) strongly typed language more similar to Java. In loosely typed languages, there is nothing stopping a developer from re-assigning a variable that is a boolean to be a string, for example. However, this can lead to a lot of bugs from accidental type re-assigning as well as typos with properties, etc. More-robust systems written in these languages tend to need a lot more manual type checking which adds to runtime complexity, more unit testing, etc. Strongly typed code is more self-documenting as well as you know what type of data an argument should take. This works great for teams and code apis where different people may need to interact with others' code. All that said, TypeScript is merely syntactic sugar to mark up JavaScript code to have type definitions. Once TypeScript is transpiled down to native Javascript, the type information is mostly lost and runtime checks should still come into play when dealing with external data (apis, user input, databases etc).

As far as this post goes, the only significant TypeScript related bit is extending the Material-UI Theme and ThemeOptions type to declare the custom theme variables. Typescript understands the "shape" of these objects from the MUI provided types. However, when you add your own properties, the typing no longer matches and produces an error. By using the module declaration syntax, we can extend the definition of the type without having to explicitly define a new type (i.e. MyTheme/MyThemeOptions or similar).