Passing a nonce generated in _document.js to _app.js within Next.js

Viewed 1504

I'm trying to implement a content security policy on my website. I am using Next.js and MUI as my component library.

Right now I'm generating a nonce within _document and my server side emotion cache has the nonce passed into it so that server side inline styles will render. However, I am also using a client side emotion cache in _app and I need to pass it the same nonce. Can anyone point me in the right direction on how to pass that nonce that is generated on the server on _document to _app on the client?

_app.js

import PropTypes from 'prop-types'
import { ThemeProvider } from '@mui/material/styles'
import { breakPoints } from '../helpers/theme'
import { SessionProvider } from 'next-auth/react'
import { useRouter } from 'next/router'
import Script from 'next/script'
import Auth from 'components/auth'
import { IntlProvider } from 'react-intl'
import localeMap from '../helpers/locale-map'
import { lightTheme } from 'helpers/theme'
import React, { useEffect, useRef, useState } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Hydrate } from 'react-query/hydration'
import getConfig from 'next/config'
import { Settings as luxonSettings } from 'luxon'
import { datadogLogs } from '@datadog/browser-logs'
import { datadogRum } from '@datadog/browser-rum'
import StoreProvider from '../store'
import { ReactQueryDevtools } from 'react-query/devtools'
import NextJSApp from 'next/app'
import { GLOBAL } from '../helpers/constants'
import { CacheProvider } from '@emotion/react'
import createEmotionCache from '../helpers/emotion-cache'
import { DefaultSeo } from 'next-seo'
import {
  generateCanonicalUrl,
  generateLanguageAlternatesConfig,
} from 'helpers/seo'
import CCPABanner from 'components/home/ccpa-banner'
import { userCountryFromHeaders } from 'helpers/localization'
import Head from 'next/head'
import { nanoid } from 'nanoid'

const { publicRuntimeConfig } = getConfig()

if (
  process.env.NODE_ENV === 'development' &&
  publicRuntimeConfig.mockApi.enabled &&
  typeof window !== 'undefined'
) {
  const { default: makeServer } = require('../queries/mock-api/server')
  makeServer()
}

if (process.env.NODE_ENV === 'production') {
  // initialize browser-logs for DataDog only for production
  datadogLogs.init(publicRuntimeConfig.datadogLogs)
  datadogRum.init(publicRuntimeConfig.datadogRum)
  datadogRum.startSessionReplayRecording()
}

export function reportWebVitals({ name, value }) {
  switch (name) {
    case 'FCP': // https://web.dev/fcp/
      datadogRum.addTiming('first-contentful-paint', value)
      break
    case 'LCP': // https://web.dev/lcp/
      datadogRum.addTiming('largest-contentful-paint', value)
      break
    case 'CLS': //https://web.dev/cls/
      datadogRum.addTiming('cumulative-layout-shift', value)
      break
    case 'FID': // https://web.dev/fid/
      datadogRum.addTiming('first-input-delay', value)
      break
    case 'TTFB': // https://web.dev/ttfb/
      datadogRum.addTiming('time-to-first-byte', value)
      break
    case 'Next.js-hydration':
      datadogRum.addTiming('hydration', value)
      break
    case 'Next.js-route-change-to-render':
      datadogRum.addTiming('route-changed', value)
      break
    case 'Next.js-render':
      datadogRum.addTiming('render-finished', value)
      break
    default:
      break
  }
}

// Client-side cache, shared for the whole session of the user in the browser.

// Use the <Provider> to improve performance and allow components that call
// `useSession()` anywhere in your application to access the `session` object.
function App({
  Component,
  pageProps,
  globalData,
  localizedMessages,
  ccpaCookiePresent,
  nonce,
}) {
  const queryClientRef = useRef()
  const storeRef = useRef({
    [GLOBAL]: globalData,
  })
  const router = useRouter()
  const { locale, defaultLocale } = router
  const [showCCPABanner, setShowCCPABanner] = useState(!ccpaCookiePresent)
  const isUSUser = globalData?.userCountry === 'US'

  luxonSettings.defaultLocale = locale

  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient(publicRuntimeConfig.queryClient)
  }
  const emotionCache = createEmotionCache(nonce)
  const Layout = Component.Layout ? Component.Layout : React.Fragment
  const AuthWrapper = Component.auth ? Auth : React.Fragment

  const setCCPACookie = () => {
    const currentDate = new Date()
    const expirationDate = new Date(
      currentDate.setMonth(currentDate.getMonth() + 6),
    )
    document.cookie = `CCPABannerShown=1; expires=${expirationDate}`
    setShowCCPABanner(false)
  }

  useEffect(() => {
    router.events.on('routeChangeComplete', (url) => {
      global.analytics.page(url)
    })
  }, [router.events])

  // useEffect(() => {
  //   TagManager.initialize({ gtmId: publicRuntimeConfig.gtm.id })
  // }, [])

  const languageAlternatesConfig = generateLanguageAlternatesConfig(router)
  const canonicalUrl = generateCanonicalUrl(router)

  //scaling for tablet and mobile views less than medium breakpoint width
  const screenWidth = typeof window !== 'undefined' && window.outerWidth
  const mediumBreakPoint = breakPoints.values.md
  const screenScaling =
    Component.NeedsMobileScaling &&
    screenWidth &&
    screenWidth < mediumBreakPoint
      ? screenWidth / mediumBreakPoint
      : 1

  return (
    <>
      <Head>
        <meta
          name="viewport"
          content={`viewport-fit=cover, initial-scale=${screenScaling}, minimum-scale=${screenScaling}`}
        />
      </Head>
      <Script
        strategy="lazyOnload"
        src={`https://connect.facebook.net/en_US/sdk.js#version=v11.0&appId=${publicRuntimeConfig.facebook.clientId}&cookie=true&xfbml=true&status=true`}
      />
      <Script
        strategy="afterInteractive"
        src={`https://consent.truste.com/notice?domain=myfitnesspal.com&c=teconsent&text=true&language=${locale}`}
      />
      <SessionProvider
        {...publicRuntimeConfig.sessionProvider}
        session={pageProps.session}
      >
        <IntlProvider
          locale={locale}
          defaultLocale={defaultLocale}
          messages={localizedMessages}
        >
          <QueryClientProvider client={queryClientRef.current}>
            <Hydrate state={pageProps.dehydratedState}>
              <CacheProvider value={emotionCache}>
                <ThemeProvider theme={lightTheme}>
                  <StoreProvider initialState={storeRef.current}>
                    <DefaultSeo
                      defaultTitle={`${localizedMessages['defaultPageTitle']} | MyFitnessPal.com`}
                      titleTemplate="%s | MyFitnessPal.com"
                      dangerouslySetAllPagesToNoIndex={
                        publicRuntimeConfig.noIndexMetaTag
                      }
                      description={
                        localizedMessages['app.sharedPageDescription']
                      }
                      languageAlternates={languageAlternatesConfig}
                      canonical={canonicalUrl}
                      facebook={{
                        appId: publicRuntimeConfig.facebook.clientId,
                      }}
                    />
                    <Layout>
                      <AuthWrapper>
                        <Component {...pageProps} />
                      </AuthWrapper>
                      {showCCPABanner && (
                        <CCPABanner
                          onClose={setCCPACookie}
                          isUSUser={isUSUser}
                        />
                      )}
                    </Layout>
                  </StoreProvider>
                </ThemeProvider>
              </CacheProvider>
              <ReactQueryDevtools position="bottom-right" />
            </Hydrate>
          </QueryClientProvider>
        </IntlProvider>
      </SessionProvider>
    </>
  )
}

const getClientOS = (headerUserAgent) => {
  if (/iOS|iPhone|iPad/.test(headerUserAgent)) return 'iOS'
  if (/Android/.test(headerUserAgent)) return 'AndroidOS'
  return 'Web'
}

App.getInitialProps = async (appContext) => {
  let nonce
  if (appContext.ctx.req) {
    nonce = nanoid()
    appContext.ctx.req.nonce = nonce
  }

  console.log(nonce, 'nonce from appContext _app')

  const appProps = await NextJSApp.getInitialProps(appContext)
  const headers = appContext.ctx.req?.headers
  const ccpaCookiePresent = !!appContext.ctx.req?.cookies['CCPABannerShown']
  const locale = appContext.router.locale
  const mappedLocale = localeMap[locale] || 'en' // This mapping is necessary because our locales do not exactly match Smartling's enforced file naming conventions.
  const localizedMessages = await import(`../locales/${mappedLocale}.json`)

  if (headers) {
    const client = getClientOS(headers['user-agent'])
    const userCountry = userCountryFromHeaders(appContext.ctx)
    appProps['globalData'] = { userCountry, client }
  }

  return {
    ...appProps,
    ccpaCookiePresent,
    localizedMessages,
    nonce,
  }
}

App.propTypes = {
  Component: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
  emotionCache: PropTypes.object,
  pageProps: PropTypes.object,
  globalData: PropTypes.object,
  ccpaCookiePresent: PropTypes.bool,
  localizedMessages: PropTypes.object,
  nonce: PropTypes.string,
}

export default App

_document.js

import Document, { Head, Html, Main, NextScript } from 'next/document'
import React from 'react'
import createEmotionServer from '@emotion/server/create-instance'
import createEmotionCache from '../../src/helpers/emotion-cache'
import getConfig from 'next/config'
import * as snippet from '@segment/snippet'
import { getUserIsPremium } from 'helpers/server'
import { QueryClient } from 'react-query'
import { getUser } from 'queries/user/queries'
import { getActiveSubscription } from 'queries/subscription/queries'
import { getSession } from 'next-auth/react'
import { prefetch } from 'queries'

export default class MyDocument extends Document {
  render() {
    const { publicRuntimeConfig } = getConfig()
    const { minified, options } = publicRuntimeConfig.segment
    const segmentSnippet = snippet[minified ? 'min' : 'max'](options)

    return (
      <Html>
        <Head>
          <link rel="icon" type="image/png" href="/favicon.png" />
          <link
            rel="stylesheet preload"
            href="https://web-assets.myfitnesspal.com/fonts/neue-plak-ua.css"
            as="style"
          />
          {/* Our GTM package react-gtm-module requires this script to access the gtag function referenced in the GTM script. Please see https://stackoverflow.com/questions/61041158/google-analytics-webapp-event-config-error-gtag-is-not-defined for more info. */}
          {/* <script
            dangerouslySetInnerHTML={{
              __html: `  window.dataLayer = window.dataLayer || [];
                        function gtag(){dataLayer.push(arguments);}`,
            }}
          /> */}
          {/* <script
            id="gtmScript"
            nonce={this.props.nonce}
            dangerouslySetInnerHTML={{
              __html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${publicRuntimeConfig.gtm.id}');`,
            }}
          ></script> */}
          {this.props.shouldLoadAdScript && (
            <>
              <script
                async
                nonce={this.props.nonce}
                src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"
              />
              <script
                nonce={this.props.nonce}
                dangerouslySetInnerHTML={{
                  __html: `window.googletag = window.googletag || {cmd: []};
                googletag.cmd.push(function() {
                  googletag.pubads().setForceSafeFrame(true);
                  });`,
                }}
              />
            </>
          )}
          <script
            nonce={this.props.nonce}
            dangerouslySetInnerHTML={{ __html: segmentSnippet }}
          />
          <meta property="csp-nonce" content={this.props.nonce} />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side 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 queryClient = new QueryClient()
  const session = await getSession(ctx)
  if (session) {
    await Promise.all([
      await queryClient.prefetchQuery(
        'subscription',
        prefetch('subscription', getActiveSubscription, ctx),
      ),
      await queryClient.prefetchQuery('user', prefetch('user', getUser, ctx)),
    ])
  }

  const userIsPremium = getUserIsPremium(queryClient)
  const isLoggedIn = !!ctx.req.cookies._mfp_session
  const shouldLoadAdScript = isLoggedIn && !userIsPremium

  const originalRenderPage = ctx.renderPage

  console.log(ctx.req.nonce, 'nonce in document')

  const nonce = ctx.req.nonce
  const docProps = await ctx.defaultGetInitialProps(ctx)

  let contentSecurityPolicy = ''
  if (process.env.NODE_ENV === 'production') {
    contentSecurityPolicy = `default-src 'self'; style-src 'self' 'nonce-${nonce}' web-assets.myfitnesspal.com fonts.googleapis.com; connect-src *; img-src * data:; font-src web-assets.myfitnesspal.com fonts.gstatic.com; frame-src consent-pref.trustarc.com bid.g.doubleclick.net *.googlesyndication.com www.google.com; script-src 'self' 'nonce-${nonce}' web-main-assets.myfitnesspal.com fonts.gstatic.com cdn.ampproject.org secure.cdn.fastclick.net *.googlesyndication.com www.googletagservices.com z.moatads.com www.googletagmanager.com connect.facebook.net cdn.segment.com consent.truste.com consent.trustarc.com http://consent.trustarc.com www.google.com www.gstatic.com/recaptcha; prefetch-src web-main-assets.myfitnesspal.com`
  } else {
    // react-refresh needs 'unsafe-eval'
    // Next.js needs 'unsafe-inline' during development https://github.com/vercel/next.js/blob/canary/packages/next/client/dev/fouc.js
    // Specifying 'nonce' makes a modern browsers ignore 'unsafe-inline'
    contentSecurityPolicy = `default-src 'self'; style-src 'unsafe-inline' web-assets.myfitnesspal.com; connect-src *; img-src * data:; font-src web-assets.myfitnesspal.com; frame-src consent-pref.trustarc.com bid.g.doubleclick.net *.googlesyndication.com www.google.com; script-src 'self' 'nonce-${nonce}' 'unsafe-eval' *.googlesyndication.com web-main-assets.myfitnesspal.com fonts.gstatic.com cdn.ampproject.org secure.cdn.fastclick.net www.googletagservices.com z.moatads.com www.googletagmanager.com connect.facebook.net cdn.segment.com consent.truste.com consent.trustarc.com http://consent.trustarc.com www.google.com; prefetch-src web-main-assets.myfitnesspal.com`
  }

  ctx.res.setHeader('Content-Security-Policy', contentSecurityPolicy)

  // console.log(ctx.res, 'nonce')

  // 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(nonce)
  const { extractCriticalToChunks } = createEmotionServer(cache)

  ctx.renderPage = () =>
    originalRenderPage({
      // eslint-disable-next-line react/display-name
      enhanceApp: (App) => (props) => <App emotionCache={cache} {...props} />,
    })

  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}
      nonce={nonce}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: style.css }}
    />
  ))

  return {
    ...initialProps,
    ...docProps,
    shouldLoadAdScript,
    nonce,
    // Styles fragment is rendered after the app and page rendering finish.
    styles: [
      ...React.Children.toArray(initialProps.styles),
      ...emotionStyleTags,
    ],
  }
}
0 Answers
Related