Collapsing menu bar in mobile with SEO support

Collapsing menu bar in mobile with SEO support

In this post, I will raise some problems when designing a navbar (menu bar) in a web app, and provide solutions to solve each problem. I will use sample code in a react application. The idea is totally the same as is any other framework.

When designing a web app for a PC screen, there is usually noticeable space to display the menu bar permanently. However, this is not true for most application when the screen size shrinks to fit mobile devices. A typical approach is collapsing menu bar and toggle it only when needed via user interaction.

Solution 1: use the internal state to control navbar visibility.

const [open, setOpen] = useState(false)
return <OutsideClick onOutsideClick={()=>void (open && setOpen(false))}>
    <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}>
        <Link to="/">Home</Link>
        <Link to="/login">Login</Link>
	</div>
	<button type="button" onClick={()=>setOpen(o => !o)}/>Toggle menu</button>
</OutsideClick>

Problem: javascript code does not work on AMP page

Solution 2: add AMP support

via amp-bind

const [open, setOpen] = useState(false)
const isAmp = useIsAmp()
return <OutsideClick onOutsideClick={()=>void (open && setOpen(false))}>
    <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}
    {...isAmp && {
        'data-amp-bind-class': `['${styles.menu}', open && '${styles.active}'].filter(x => !!x).join(' ')`
    }}>
        <Link to="/">Home</Link>
        <Link to="/login">Login</Link>
	</div>
	<button type="button"
			{...isAmp && {on: 'tap:AMP.setState({open: !open})'}}
			onClick={()=>setOpen(o => !o)}/>Toggle menu</button>
</OutsideClick>

Do not forget to include amp-bind script in the head section of the HTML page.

Some css optimization libraries (e.g. purify-css) prune a class name if it does not appear in the document structure. Hence, you should take care and add the dynamic class name to the whitelist setting appropriately.

purifyCss(body, cssContent, {whitelist: [styles.active]})

Problem 1: SEO support. Google does not recognize javascript and it will never expand our menu

Problem 2: the menu will not open, interacts with user operation as long as hydration has not finished

Solution 3 (final): add SEO support, interact with user regardless of the hydration process

The idea is simple: instead of storing menu status as an internal state, store it in a URL query. i.e. migrate the source of truth from internal state to query.

import queryString from 'query-string'

const NoClickLink = ({to, ampProps, ...props}: ComponentProps<typeof Link> & {ampProps: {[key: string]: string}}) => {
  const amp = useAmp()
  return amp ? <a href="#" {...props} {...ampProps}/> : <Link to={to} {...props}/>
}
NoClickLink.displayName = 'NoClickLink'


const MenusBar = () => {
	const query = useQuery()
    const open = query.menu === null
    const {history, location} = useRouter()
    const isAmp = useIsAmp()
    return <OutsideClick onOutsideClick={()=>void (open && history.push({...location, search: queryString.stringify({...query, menu: undefined})})}>
        <div className={[styles.menu, open && styles.active].filter(Boolean).join(' ')}
        {...isAmp && {
            'data-amp-bind-class': `['${styles.menu}', open && '${styles.active}'].filter(x => !!x).join(' ')`
        }}>
            <Link to="/">Home</Link>
            <Link to="/login">Login</Link>
        </div>
        <NoClickLink
                ampProps={{on: 'tap:AMP.setState({open: !open})'}}
                to={{...location, search: queryString.stringify({...query, menu: open ? undefined : null})}}
                />Toggle menu</button>
    </OutsideClick>
}
  • The reason that NoClickLink component is created is that if to prop of the Link component is set to "#", it will render an anchor <a> tag with full url as of the current page (instead of <a href='#'>).

Thus, we have to use <a> directly in amp page. Otherwise, the page will be navigated when user clicks the toggle button in AMP page.

  • One more advantage of this solution is that it also closes the menu when navigating to a new page via clicking menu from the menus bar. Because the navigation also reset query parameter in URL.
    With solution 1 and solution 2, the internal state keeps unchanged if the navigation does not cause re-rendering. Which adds more complication to the current code to control whether the url changed
//this code is not neccesary in method 3. Only used in method 1, and 2
const {location: {path, search, hash}} = useRouter()
const fullPath = path + search + hash
const prevFullPath  = usePrev(fullPath)
useEffect(() => void(fullPath !== prevFullPath && open && setOpen(false)), [fullPath, prevFullPath, open])
Buy Me A Coffee