skip to Main Content

It feels like this question has been asked a hundred times. So, it’s probably a hard question. I’ll provide a bit more detail for my case. Maybe it’ll help.

I’m using Next.js ^10.0.9 and next-seo ^4.22.0. I can see the meta tags in devtools but FB and Twitter and a lot of other meta tag validators can’t pick them up.

From several other questions on here and elsewhere it seems there is some agreement that as long as it’s not in the source, i.e., if we "inspect source" and can’t see it, then it’s not available to scrapers. As I understand it, this is because these bots don’t run the JavaScript needed to render the meta tags.

Source

This page source should contain opengraph meta tags for description, title, images, and several other things, but it doesn’t:

<!DOCTYPE html>
<html lang="en">
    <head>
        <style data-next-hide-fouc="true">
        body{display:none}
    </style>
    <noscript data-next-hide-fouc="true">
        <style>
        body{display:block}
    </style>
</noscript>

<meta charSet="utf-8"/>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width"/>
<meta name="next-head-count" content="2"/>

<noscript data-n-css=""></noscript>

<link rel="preload" href="/_next/static/chunks/main.js?ts=1616232654196" as="script"/>
<link rel="preload" href="/_next/static/chunks/webpack.js?ts=1616232654196" as="script"/>
<link rel="preload" href="/_next/static/chunks/pages/_app.js?ts=1616232654196" as="script"/>
<link rel="preload" href="/_next/static/chunks/pages/index.js?ts=1616232654196" as="script"/>

<noscript id="__next_css__DO_NOT_USE__"></noscript>

<style id="jss-server-side">html {
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after {
  box-sizing: inherit;
}
strong, b {
  font-weight: 700;
}
body {
  color: rgba(0, 0, 0, 0.87);
  margin: 0;
  font-size: 0.875rem;
  font-family: "Roboto", "Helvetica", "Arial", sans-serif;
  font-weight: 400;
  line-height: 1.43;
  letter-spacing: 0.01071em;
  background-color: #c3d0f5;
}
@media print {
  body {
    background-color: #fff;
  }
}
body::backdrop {
  background-color: #c3d0f5;
}</style>

</head>

<body>
    <div id="__next">
        <div class="Loading__Center-sc-9gpo7v-0 dctQei">
            <div style="width:100%;height:100%;overflow:hidden;margin:0 auto;outline:none" title="" role="button" aria-label="animation" tabindex="0">
            </div>
            <h2>This is you. This is how you wait.</h2>
        </div>
    </div>
    
    <script src="/_next/static/chunks/react-refresh.js?ts=1616232654196"></script>
    
    <script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/","query":{},"buildId":"development","runtimeConfig":{"APP_NAME":"example","APP_DESCRIPTION":"Discover examples of examples of examples.","APP_URL":"https://example.com","nextExport":true,"autoExport":true,"isFallback":false}</script>
    
    <script nomodule="" src="/_next/static/chunks/polyfills.js?ts=1616232654196"></script>
    <script src="/_next/static/chunks/main.js?ts=1616232654196"></script>
    <script src="/_next/static/chunks/webpack.js?ts=1616232654196"></script>
    <script src="/_next/static/chunks/pages/_app.js?ts=1616232654196"></script>
    <script src="/_next/static/chunks/pages/index.js?ts=1616232654196"></script>
    <script src="/_next/static/development/_buildManifest.js?ts=1616232654196"></script>
    <script src="/_next/static/development/_ssgManifest.js?ts=1616232654196"></script>
</body>
</html>

Pages generated at build time?

It puzzles me that Next.js isn’t rendering them. According to the docs:

By default, Next.js pre-renders every page. This means that Next.js
generates HTML for each page in advance, instead of having it all done
by client-side JavaScript. Pre-rendering can result in better
performance and SEO.

Unless I’m misreading this, it means that as long I’m using the default setup, pages will be rendered either through SSR or Static Generation. Right?

Moreover, the source above mentions "nextExport":true,"autoExport":true, which I think indicates that the page should be created at build time.

My code may have gone through some changes but I’m pretty sure they’ve not deviated from SSR or Static Generation.

High level view of code

At some point I added _document.tsx and _app.tsx.

I’ve not changed _document.tsx much. After some experimentation I gather it’s vital to have Head from next/document here:

import Document, { Head, Html, Main, NextScript } from "next/document";

import React from "react";
import { ServerStyleSheets } from "@material-ui/core/styles";
import theme from "../styles/theme";

export default class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

MyDocument.getInitialProps = async (ctx) => {
  const sheets = new ServerStyleSheets();
  const originalRenderPage = ctx.renderPage;

  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
    });

  const initialProps = await Document.getInitialProps(ctx);

  return {
    ...initialProps,
    styles: [
      ...React.Children.toArray(initialProps.styles),
      sheets.getStyleElement(),
    ],
  };
};

_app.tsx has more changes, but nothing I think would affect the meta tags. If I manually place meta tags manually in Head here, they’d show up in source. Works well for defaults, but I’d like to be able to do it programmatically for every page.

import { useEffect, useState } from "react";

import { AppProps } from "next/app";
import CssBaseline from "@material-ui/core/CssBaseline";
import Layout from "../components/Layout";
import { ThemeProvider } from "@material-ui/core/styles";
import { User } from "../context/user";
import theme from "../styles/theme";

export default function App({ Component, pageProps }: AppProps) {
  const [user, setUser] = useState({
    auth: null,
    loading: true,
  });

  useEffect(() => {
    const getUser = async () => {
      const res = await fetch("/api/auth/me");
      const auth = res.ok ? await res.json() : null;
      setUser({ auth, loading: false });
    };
    getUser();

    const jssStyles = document.querySelector("#jss-server-side");
    if (jssStyles) {
      jssStyles &&
        jssStyles.parentElement &&
        jssStyles.parentElement.removeChild(jssStyles);
    }
  }, []);

  return (
    <>
      <User.Provider value={{ user, setUser }}>
        <ThemeProvider theme={theme}>
          <CssBaseline />
          <Layout>
            <Component {...pageProps} />
          </Layout>
        </ThemeProvider>
      </User.Provider>
    </>
  );
}

I created a Head component using next-seo. It does quite a bit of work. Perhaps just note that it directly returns NextSeo, without putting it into a React fragment or other components:

import { NextPage } from "next";
import { NextSeo } from "next-seo";
import React from "react";
import getConfig from "next/config";
const { publicRuntimeConfig } = getConfig();
interface Props {
  page?: string;
  description?: string;
  image?: {
    url: string;
    alt: string;
  };
}

const appDescription = publicRuntimeConfig.APP_DESCRIPTION;
const appName = publicRuntimeConfig.APP_NAME;
const appUrl = publicRuntimeConfig.APP_URL;
const twitter = publicRuntimeConfig.TWITTER;

const PageHead: NextPage<Props> = ({ page, description, image }: Props) => {
  const pageTitle = page ? `${page} | ${appName}` : appName;
  const pageDescription = description ?? appDescription;

  let pageUrl;
  let isItemPage;

  if (typeof window !== "undefined") {
    pageUrl = window.location.href ?? appUrl;
    isItemPage = window.location.pathname.includes("item");
  }

  const disallowRobot = isItemPage ? true : false;

  const pageImage = image ?? {
    url: `${appUrl}/logo.png`,
    width: 400,
    height: 400,
    alt: `${appName} logo`,
  };

  return (
    <NextSeo
      title={pageTitle}
      description={pageDescription}
      canonical={pageUrl}
      openGraph={{
        url: pageUrl,
        title: pageTitle,
        description: pageDescription,
        images: [pageImage],
        site_name: appName,
      }}
      twitter={{
        handle: twitter,
        site: twitter,
        cardType: "summary_large_image",
      }}
      noindex={disallowRobot}
      nofollow={disallowRobot}
    />
  );
};

export default PageHead;

Here’s how it might be used in a component/page. This page has dynamic content coming from a third-party API, and the URL depends on that contant. I’m not sure, but I think this page is created through Static Generation:

import Head from "../../components/Head";

interface Props {
  item: ContentProps;
}

const MostLikedThings: NextPage<Props> = ({ item }: Props) => {
  ...

  return (
    <>
      <Head
        page={item.title}
        description={item.synopsis}
        image={...}
      />
      <MainWrapper>
          ...
      </MainWrapper>
    </>
  );
};

export default MostLikedThings;

export async function getStaticPaths() {
  return {
    paths: [],
    fallback: true,
  };
}

export async function getStaticProps({ params }: { params: { id: string } }) {
  const item = await (
    await fetch(`/api/content`)
  ).json();
  return {
    props: { item },
    revalidate: 86400,
  };
}

A simpler page without need for external content looks like the code below. I believe it’s also made with Static Generation since (as I understand it) I’ve not used getServerSideProps with it:

import Head from "../components/Head";
import Main from "../apps/main/index";
import { NextPage } from "next";

const Home: NextPage = () => {

  return (
    <>
      <Head page="Home" />
      <Main />
    </>
  );
};

export default Home;

I believe both pages are being generated through Static Generation, right? In which case, they’re both generated at build time. Which means the meta tags should be in the source. So, why aren’t they?

Several attempts made

I’ve tried quite a number of things including:

  • using getServerSideProps in an attempt to somehow be more explicit in making sure the page is generated server-side
  • removing Head from _app.tsx and _document.tsx
  • using just the html tag head in the pages to manually set meta tags
  • using next/head rather than next-seo directly in the pages to manually set meta tags:
<Head>
        ...
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:site" content={twitter} />
        <meta name="twitter:creator" content={twitter} />
        ...
</Head>

None of them worked.

One last thing to note is, I read here that:

title, meta or any other elements (e.g. script) need to be contained
as direct children of the Head element, or wrapped into maximum one
level of <React.Fragment> or arrays—otherwise the tags won’t be
correctly picked up on client-side navigations.

I think I’ve made sure of that.

A few of the many questions I’ve read through:

Have I missed anything?

Thanks.

3

Answers


  1. Chosen as BEST ANSWER

    After experimenting based on other people's unanswered questions for the umpteenth time I discovered I have two problems.

    A thing to note first: the main pages are definitely created at build time (Static Generation). In my question I was unsure. Now I'm sure.

    On to the problems.

    The loading screen problem

    It's a common pattern to have a loading screen before data loads. If we have a loading screen, that's what's created at build time.

    My components are nested like this:

        <Layout> // loading screen here
          <Navbar />
          <Main>{children!}</Main> // NextSeo here
        </Layout>
    

    The loading screen is in <Layout /> but my <NextSeo /> implementations are in <Main />. Here's my loading code in Layout:

      if (user && user.loading)
        return (
          <Loading />
        );
    

    So, even though the page got correctly rendered at runtime by the browser, Next.js built only the Loading component at build time. Probably because it's the first thing it received. And that's what the various meta tag validators scraped.

    Unfortunately, I didn't know this when I wrote my question, so probably nobody would have been able to figure this out since I didn't know to give an idea of how all the components and implementations were nested. My bad.

    In my case, I kind of deprecated the user loading thing and was able to replace it with this solution:

      const router = useRouter();
    
      const [pageLoading, setPageLoading] = useState<boolean>(false);
    
      useEffect(() => {
        const handleStart = () => {
          setPageLoading(true);
        };
        const handleComplete = () => {
          setPageLoading(false);
        };
    
        router.events.on("routeChangeStart", handleStart);
        router.events.on("routeChangeComplete", handleComplete);
        router.events.on("routeChangeError", handleComplete);
      }, [router]);
    

    If I do need to have user data again, I might have to employ getServerSideProps in addition to useRouter. I don't know yet. This is just to say your use case might require something a little different from mine.

    getServerSideProps or getStaticProps?

    My second issue was a misuse/misconception of getStaticPath and getStaticProps. According to the docs:

    getStaticPaths (Static Generation): Specify dynamic routes to pre-render pages based on data.

    My use case involves getting a dynamic id from a third-party API and then using that to build a URL and fetch content for it. So I used getStaticPath and getStaticProps together to do it. These create pages at build time. So, the pages never received the data required for the meta tags.

    Because the docs are explicit about using dynamic paths with those two methods, but not so explicit when it comes to getServerSideProps, I got the impression that this shouldn't be done with getServerSideProps.

    It turns out, I was wrong. We can use getServerSideProps with dynamic paths. So instead of this:

    export async function getStaticPaths() {
      return {
        paths: [],
        fallback: true,
      };
    }
    
    export async function getStaticProps({ params }: { params: { id: string } }) {
      let item = await (
        await fetch(
          `/api/content/?id=${params.id}`
        )
      ).json();
    
      return {
        props: { item },
        revalidate: 86400,
      };
    }
    

    I now do this:

    export async function getServerSideProps({
      params,
    }: {
      params: { id: string };
    }) {
      let item = await (
        await fetch(
          `/api/content/?id=${params.id}`
        )
      ).json();
    
      return {
        props: { item },
      };
    }
    

    Because getServerSideProps generates the page on request, it provides the meta tags anew every time.


  2. Thanks for posting your solution. I implemented the NextJS router loader strategy you outlined and the metadata is now being detected by Facebook’s debugger—and everywhere else I’ve tested.

    That said, I’m still using getStaticProps to generate static pages for dynamic routes. Even though the page source doesn’t contain the open graph meta tags, they are still being detected.

    Login or Signup to reply.
  3. seems like we was experiencing close issues in my opinion.

    My problem was programmatically create just one pages meta tags and every other pages stay same. So i ended up creating an Context named <MetaContext />.

    I wrapped my _app.jsx with the <MetaContext.Provider value={{setSEO}} />

    setSEO function from _app.jsx:

    const [metaImage, setMetaImage] = useState('link/og.png')
      const [metaTitle, setMetaTitle] = useState('name')
      const [metaDescription, setMetaDescription] = useState(
        'description'
      )
      const [SEOWorked, setSEOWorked] = useState(false)
      const [pageLoading, setPageLoading] = useState(false)
    
      const setSEO = (metaImage, metaTitle, metaDescription) => {
        setMetaImage(metaImage)
        setMetaTitle(metaTitle)
        setMetaDescription(metaDescription)
        setSEOWorked(true)
      }
    

    <NextSeo /> component from _app.jsx:

                 {setSEOWorked == true && (
                    <NextSeo
                      title={metaTitle}
                      description={metaDescription}
                      openGraph={{
                        title: metaTitle,
                        description: metaDescription,
                        site_name: 'name',
                        images: [
                          {
                            url: metaImage,
                          },
                        ],
                      }}
                      twitter={{
                        site: '@tag',
                        cardType: 'summary_large_image',
                      }}
                    />
                  )}
    

    So if page is not same with the page i want to change programmatically i wrapped up a useEffect function. (so every other page will have default metatags)

    useEffect(() => {
        console.log(router.pathname)
    
        if (router.pathname !== '/[username]/[id]') {
          setSEO(
            'link/og.png',
            'name',
            'description'
          )
        }
      }, [])
    

    The page defines variables metaImage, metaTitle, metaDescription via <MetaContext />:

    const { setSEO } = useContext(MetaContext)
    
      
    
        useEffect(() => {
            setSEO(
              `link`,
              variable,
              `view`
            )
          }, [pixel])
    

    pixel and variable comes from getServerSideProps() correctly.

    Still Facebook, iframely or Twitter Card Validator can’t pick up meta tags correctly.

    Note: Links, some variables, brand names & other things replaced while publishing.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search