Search code examples
reactjsnext.jsserver-side-renderingdevice-detection

How to detect the device on React SSR App with Next.js?


on a web application I want to display two different Menu, one for the Mobile, one for the Desktop browser. I use Next.js application with server-side rendering and the library react-device-detect.

Here is the CodeSandox link.

import Link from "next/link";
import { BrowserView, MobileView } from "react-device-detect";

export default () => (
  <div>
    Hello World.{" "}
    <Link href="/about">
      <a>About</a>
    </Link>
    <BrowserView>
      <h1> This is rendered only in browser </h1>
    </BrowserView>
    <MobileView>
      <h1> This is rendered only on mobile </h1>
    </MobileView>
  </div>
);

If you open this in a browser and switch to mobile view and look the console you get this error:

Warning: Text content did not match. Server: " This is rendered only in browser " Client: " This is rendered only on mobile "

This happen because the rendering by the server detects a browser and on the client, he is a mobile device. The only workaround I found is to generate both and use the CSS like this:

.activeOnMobile {
  @media screen and (min-width: 800px) {
    display: none;
  }
}

.activeOnDesktop {
  @media screen and (max-width: 800px) {
    display: none;
  }
}

Instead of the library but I don't really like this method. Does someone know the good practice to handle devices type on an SSR app directly in the react code?


Solution

  • LATEST UPDATE:

    So if you don't mind doing it client side you can use the dynamic importing as suggested by a few people below. This will be for use cases where you use static page generation.

    i created a component which passes all the react-device-detect exports as props (it would be wise to filter out only the needed exports because then does not treeshake)

    // Device/Device.tsx
    
    import { ReactNode } from 'react'
    import * as rdd from 'react-device-detect'
    
    interface DeviceProps {
      children: (props: typeof rdd) => ReactNode
    }
    export default function Device(props: DeviceProps) {
      return <div className="device-layout-component">{props.children(rdd)}</div>
    }
    
    
    // Device/index.ts
    
    import dynamic from 'next/dynamic'
    
    const Device = dynamic(() => import('./Device'), { ssr: false })
    
    export default Device
    
    

    and then when you want to make use of the component you can just do

    const Example = () => {
      return (
        <Device>
          {({ isMobile }) => {
            if (isMobile) return <div>My Mobile View</div>
            return <div>My Desktop View</div>
          }}
        </Device>
      )
    }
    

    Personally I just use a hook to do this, although the initial props method is better.

    import { useEffect } from 'react'
    
    const getMobileDetect = (userAgent: NavigatorID['userAgent']) => {
      const isAndroid = () => Boolean(userAgent.match(/Android/i))
      const isIos = () => Boolean(userAgent.match(/iPhone|iPad|iPod/i))
      const isOpera = () => Boolean(userAgent.match(/Opera Mini/i))
      const isWindows = () => Boolean(userAgent.match(/IEMobile/i))
      const isSSR = () => Boolean(userAgent.match(/SSR/i))
      const isMobile = () => Boolean(isAndroid() || isIos() || isOpera() || isWindows())
      const isDesktop = () => Boolean(!isMobile() && !isSSR())
      return {
        isMobile,
        isDesktop,
        isAndroid,
        isIos,
        isSSR,
      }
    }
    const useMobileDetect = () => {
      useEffect(() => {}, [])
      const userAgent = typeof navigator === 'undefined' ? 'SSR' : navigator.userAgent
      return getMobileDetect(userAgent)
    }
    
    export default useMobileDetect
    

    I had the problem that scroll animation was annoying on mobile devices so I made a device based enabled scroll animation component;

    import React, { ReactNode } from 'react'
    import ScrollAnimation, { ScrollAnimationProps } from 'react-animate-on-scroll'
    import useMobileDetect from 'src/utils/useMobileDetect'
    
    interface DeviceScrollAnimation extends ScrollAnimationProps {
      device: 'mobile' | 'desktop'
      children: ReactNode
    }
    
    export default function DeviceScrollAnimation({ device, animateIn, animateOut, initiallyVisible, ...props }: DeviceScrollAnimation) {
      const currentDevice = useMobileDetect()
    
      const flag = device === 'mobile' ? currentDevice.isMobile() : device === 'desktop' ? currentDevice.isDesktop() : true
    
      return (
        <ScrollAnimation
          animateIn={flag ? animateIn : 'none'}
          animateOut={flag ? animateOut : 'none'}
          initiallyVisible={flag ? initiallyVisible : true}
          {...props}
        />
      )
    }
    

    UPDATE:

    so after further going down the rabbit hole, the best solution i came up with is using the react-device-detect in a useEffect, if you further inspect the device detect you will notice that it exports const's that are set via the ua-parser-js lib

    export const UA = new UAParser();
    
    export const browser = UA.getBrowser();
    export const cpu = UA.getCPU();
    export const device = UA.getDevice();
    export const engine = UA.getEngine();
    export const os = UA.getOS();
    export const ua = UA.getUA();
    export const setUA = (uaStr) => UA.setUA(uaStr);
    

    This results in the initial device being the server which causes false detection.

    I forked the repo and created and added a ssr-selector which requires you to pass in a user-agent. which could be done using the initial props


    UPDATE:

    Because of Ipads not giving a correct or rather well enough defined user-agent, see this issue, I decided to create a hook to better detect the device

    import { useEffect, useState } from 'react'
    
    function isTouchDevice() {
      if (typeof window === 'undefined') return false
      const prefixes = ' -webkit- -moz- -o- -ms- '.split(' ')
      function mq(query) {
        return typeof window !== 'undefined' && window.matchMedia(query).matches
      }
      // @ts-ignore
      if ('ontouchstart' in window || (window?.DocumentTouch && document instanceof DocumentTouch)) return true
      const query = ['(', prefixes.join('touch-enabled),('), 'heartz', ')'].join('') // include the 'heartz' - https://git.io/vznFH
      return mq(query)
    }
    
    export default function useIsTouchDevice() {
      const [isTouch, setIsTouch] = useState(false)
      useEffect(() => {
        const { isAndroid, isIPad13, isIPhone13, isWinPhone, isMobileSafari, isTablet } = require('react-device-detect')
        setIsTouch(isTouch || isAndroid || isIPad13 || isIPhone13 || isWinPhone || isMobileSafari || isTablet || isTouchDevice())
      }, [])
    
      return isTouch
    

    Because I require the package each time I call that hook, the UA info is updated, it also fixes to SSR out of sync warnings.