reactjsmaterial-uinext.jsserver-side-rendering

How to implement SSR for Material UI's media queries in NextJs?


I can't follow the documentation of implementing Material UI's media queries because it's specified for a plain React app and I'm using NextJs. Specifically, I don't know where to put the following code that the documentation specifies:

import ReactDOMServer from 'react-dom/server';
import parser from 'ua-parser-js';
import mediaQuery from 'css-mediaquery';
import { ThemeProvider } from '@material-ui/core/styles';

function handleRender(req, res) {
  const deviceType = parser(req.headers['user-agent']).device.type || 'desktop';
  const ssrMatchMedia = query => ({
    matches: mediaQuery.match(query, {
      // The estimated CSS width of the browser.
      width: deviceType === 'mobile' ? '0px' : '1024px',
    }),
  });

  const html = ReactDOMServer.renderToString(
    <ThemeProvider
      theme={{
        props: {
          // Change the default options of useMediaQuery
          MuiUseMediaQuery: { ssrMatchMedia },
        },
      }}
    >
      <App />
    </ThemeProvider>,
  );

  // …
}

The reason that I want to implement this is because I use media queries to conditionally render certain components, like so:

const xs = useMediaQuery(theme.breakpoints.down('sm'))
...
return(
  {xs ?
     <p>Small device</p>
  :
     <p>Regular size device</p>
  }
)

I know that I could use Material UI's Hidden but I like this approach where the media queries are variables with a state because I also use them to conditionally apply css.

I'm already using styled components and Material UI's styles with SRR. This is my _app.js

  import NextApp from 'next/app'
  import React from 'react'
  import { ThemeProvider } from 'styled-components'

  const theme = { 
    primary: '#4285F4'
  }

  export default class App extends NextApp {
    componentDidMount() {
      const jssStyles = document.querySelector('#jss-server-side')
      if (jssStyles && jssStyles.parentNode)
        jssStyles.parentNode.removeChild(jssStyles)
    }

    render() {
      const { Component, pageProps } = this.props

      return (
        <ThemeProvider theme={theme}>
          <Component {...pageProps} />
          <style jsx global>
            {`  
              body {
                margin: 0;
              }   
              .tui-toolbar-icons {
                background: url(${require('~/public/tui-editor-icons.png')});
                background-size: 218px 188px;
                display: inline-block;
              }   
            `}  
          </style>
        </ThemeProvider>
      )   
    }
  }

And this is my _document.js

import React from 'react'
import { Html, Head, Main, NextScript } from 'next/document'

import NextDocument from 'next/document'

import { ServerStyleSheet as StyledComponentSheets } from 'styled-components'
import { ServerStyleSheets as MaterialUiServerStyleSheets } from '@material-ui/styles'

export default class Document extends NextDocument {
  static async getInitialProps(ctx) {
    const styledComponentSheet = new StyledComponentSheets()
    const materialUiSheets = new MaterialUiServerStyleSheets()
    const originalRenderPage = ctx.renderPage

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: App => props =>
            styledComponentSheet.collectStyles(
              materialUiSheets.collect(<App {...props} />)
            )   
        })  

      const initialProps = await NextDocument.getInitialProps(ctx)

      return {
        ...initialProps,
        styles: [
          <React.Fragment key="styles">
            {initialProps.styles}
            {materialUiSheets.getStyleElement()}
            {styledComponentSheet.getStyleElement()}
          </React.Fragment>
        ]   
      }   
    } finally {
      styledComponentSheet.seal()
    }   
  }

  render() {
    return (
      <Html lang="es">
        <Head>
          <link
            href="https://fonts.googleapis.com/css?family=Comfortaa|Open+Sans&display=swap"
            rel="stylesheet"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )   
  }
}

Solution

  • First a caveat -- I do not currently have any experience using SSR myself, but I have deep knowledge of Material-UI and I think that with the code you have included in your question and the Next.js documentation, I can help you work through this.

    You are already showing in your _app.js how you are setting your theme into your styled-components ThemeProvider. You will also need to set a theme for the Material-UI ThemeProvider and you need to choose between two possible themes based on device type.

    First define the two themes you care about. The two themes will use different implementations of ssrMatchMedia -- one for mobile and one for desktop.

    import mediaQuery from 'css-mediaquery';
    import { createMuiTheme } from "@material-ui/core/styles";
    
    const mobileSsrMatchMedia = query => ({
      matches: mediaQuery.match(query, {
        // The estimated CSS width of the browser.
        width: "0px"
      })
    });
    const desktopSsrMatchMedia = query => ({
      matches: mediaQuery.match(query, {
        // The estimated CSS width of the browser.
        width: "1024px"
      })
    });
    
    const mobileMuiTheme = createMuiTheme({
      props: {
        // Change the default options of useMediaQuery
        MuiUseMediaQuery: { ssrMatchMedia: mobileSsrMatchMedia }
      }
    });
    const desktopMuiTheme = createMuiTheme({
      props: {
        // Change the default options of useMediaQuery
        MuiUseMediaQuery: { ssrMatchMedia: desktopSsrMatchMedia }
      }
    });
    

    In order to choose between the two themes, you need to leverage the user-agent from the request. Here's where my knowledge is very light, so there may be minor issues in my code here. I think you need to use getInitialProps (or getServerSideProps in Next.js 9.3 or newer). getInitialProps receives the context object from which you can get the HTTP request object (req). You can then use req in the same manner as in the Material-UI documentation example to determine the device type.

    Below is an approximation of what I think _app.js should look like (not executed, so could have minor syntax issues, and has some guesses in getInitialProps since I have never used Next.js):

    import NextApp from "next/app";
    import React from "react";
    import { ThemeProvider } from "styled-components";
    import { createMuiTheme, MuiThemeProvider } from "@material-ui/core/styles";
    import mediaQuery from "css-mediaquery";
    import parser from "ua-parser-js";
    
    const theme = {
      primary: "#4285F4"
    };
    
    const mobileSsrMatchMedia = query => ({
      matches: mediaQuery.match(query, {
        // The estimated CSS width of the browser.
        width: "0px"
      })
    });
    const desktopSsrMatchMedia = query => ({
      matches: mediaQuery.match(query, {
        // The estimated CSS width of the browser.
        width: "1024px"
      })
    });
    
    const mobileMuiTheme = createMuiTheme({
      props: {
        // Change the default options of useMediaQuery
        MuiUseMediaQuery: { ssrMatchMedia: mobileSsrMatchMedia }
      }
    });
    const desktopMuiTheme = createMuiTheme({
      props: {
        // Change the default options of useMediaQuery
        MuiUseMediaQuery: { ssrMatchMedia: desktopSsrMatchMedia }
      }
    });
    
    export default class App extends NextApp {
      static async getInitialProps(ctx) {
        // I'm guessing on this line based on your _document.js example
        const initialProps = await NextApp.getInitialProps(ctx);
        // OP's edit: The ctx that we really want is inside the function parameter "ctx"
        const deviceType =
          parser(ctx.ctx.req.headers["user-agent"]).device.type || "desktop";
        // I'm guessing on the pageProps key here based on a couple examples
        return { pageProps: { ...initialProps, deviceType } };
      }
      componentDidMount() {
        const jssStyles = document.querySelector("#jss-server-side");
        if (jssStyles && jssStyles.parentNode)
          jssStyles.parentNode.removeChild(jssStyles);
      }
    
      render() {
        const { Component, pageProps } = this.props;
    
        return (
          <MuiThemeProvider
            theme={
              pageProps.deviceType === "mobile" ? mobileMuiTheme : desktopMuiTheme
            }
          >
            <ThemeProvider theme={theme}>
              <Component {...pageProps} />
              <style jsx global>
                {`
                  body {
                    margin: 0;
                  }
                  .tui-toolbar-icons {
                    background: url(${require("~/public/tui-editor-icons.png")});
                    background-size: 218px 188px;
                    display: inline-block;
                  }
                `}
              </style>
            </ThemeProvider>
          </MuiThemeProvider>
        );
      }
    }