diff --git a/docs/seo.md b/docs/seo.md new file mode 100644 index 0000000..f018a5c --- /dev/null +++ b/docs/seo.md @@ -0,0 +1,32 @@ +# SEO + +## Web performance + +Optimizing websites for speed is one of our core reasons for pursuing a headless approach with WordPress. Since 2018, [page speed has been a factor for Google](https://developers.google.com/web/updates/2018/07/search-ads-speed) in ranking both paid search and ads. In 2021, it took on increased prominence in organic results with an increased emphasis on [Core Web Vitals and page experience](https://developers.google.com/search/blog/2021/04/more-details-page-experience). + +Next.js does much of this for us out of the box, but there are a few things to be aware of with our implementation. + +- Using [next/image](https://nextjs.org/docs/api-reference/next/image) to generate responsive images. For more, read our image handling docs. +- Using [next/link](https://nextjs.org/docs/api-reference/next/link) to handle transitions between pages. + +## Per page meta tags + +The primary way we tackle this is to combine the [WordPress Yoast SEO plugin](https://yoast.com/wordpress/plugins/seo/) with [next-seo](https://github.com/garmeeh/next-seo) to allow editors to populate each page with unique meta tags optimized for both search and social networks. + +Inside of our [Meta component](../website/src/components/Meta.js), we merge together defaults defined in `lib/constants.js` and merge in passed in yoast data pulled from graphql via globals context. + +Inside of the constants file, you'll want to set per site values like site name and favicons. An important variable to set is the `META.url` to the production domain. This is used to rewrite relative images and relative links from sitemaps and feeds so that those are only indexed from your production URL. + +## Sitemaps and RSS Feeds + +One approach to sitemaps is to [generate them in Next](https://www.npmjs.com/package/next-sitemap). You can similarly create a feed using [some custom code](https://ashleemboyer.com/how-i-added-an-rss-feed-to-my-nextjs-site) and the feed npm package. + +We find however that WordPress already does a great job at this, including specific support for features like Google News indexing through a variety of well tested plugins. We've therefore implemented a lightweight proxy that grabs sitemap files from WordPress, serving them on your domain direct to search engines. Inside of [next.config.js](../website/next.config.js) we set a couple rewrites that point any requests to `*sitemap.xml` or `feed*` and retrieve them from the WordPress origin. We rewrite any absolute links so that they point to your next.js public domain. + +## Further reading + +- [Lighthouse SEO audits](https://web.dev/lighthouse-seo/) +- [Next.js introduction to SEO](https://nextjs.org/learn/seo/introduction-to-seo/importance-of-seo) +- Next.js 12 [bot aware ISR fallback](https://nextjs.org/blog/next-12#bot-aware-isr-fallback) +- [Core web vitals report](https://support.google.com/webmasters/answer/9205520?hl=en) +- [Vercel Analytics](https://vercel.com/analytics) diff --git a/website/next.config.js b/website/next.config.js index 8f522c4..ea69f9e 100644 --- a/website/next.config.js +++ b/website/next.config.js @@ -21,6 +21,7 @@ module.exports = withBundleAnalyzer({ async rewrites() { return [ + // these two rules are used to locally serve (and rewrite urls) from WP { source: '/(.*)sitemap.xml', destination: '/api/upstream-proxy', @@ -29,19 +30,11 @@ module.exports = withBundleAnalyzer({ source: '/feed', destination: '/api/upstream-proxy', }, - ]; - }, - - // For some projects, /wp-content upload paths still need to resolve - // Proxy the resource up to Wordpress. Uncomment to enable. - /*async redirects() { - return [ + // resolve relative links to WP upload assets { source: '/wp-content/uploads/:path*', - destination: - 'https://bubsnext.wpengine.com/wp-content/uploads/:path*', - permanent: true, + destination: `https://${process.env.WORDPRESS_DOMAIN}/wp-content/uploads/:path*`, }, ]; - },*/ + }, }); diff --git a/website/src/components/Meta.js b/website/src/components/Meta.js index 73a92bd..eba9b22 100644 --- a/website/src/components/Meta.js +++ b/website/src/components/Meta.js @@ -1,6 +1,6 @@ import GlobalsContext from 'contexts/GlobalsContext'; -import { META } from 'lib/constants'; -import { nextLoader } from 'lib/image-loaders'; +import { META, WORDPRESS_URL } from 'lib/constants'; +import { trimTrailingSlash } from 'lib/utils'; import { NextSeo } from 'next-seo'; import Head from 'next/head'; import { useContext } from 'react'; @@ -8,43 +8,60 @@ import { useContext } from 'react'; export default function Meta({ title, description, image, seo }) { const globals = useContext(GlobalsContext); + // make sure image is absolute + function imagePath(imageUrl) { + // if enabled rewrite WordPress image paths to local (origin often has noindex tags) + if (META.proxyWordPressImages && WORDPRESS_URL) { + let newUrl = imageUrl.replace(WORDPRESS_URL, ''); + imageUrl = newUrl; + } + // if root relative, make absolute so that twitter doesn't complain + if ( + imageUrl.indexOf('http://') === -1 && + imageUrl.indexOf('https://') === -1 && + META.url && + imageUrl.startsWith('/') + ) { + imageUrl = trimTrailingSlash(META.url) + imageUrl; + } + + return imageUrl; + } + + // if passing in a title string, append default append if defined in META. + let generatedTitle = title ? `${title} ${META.titleAppend}` : ''; + + // populate SEO settings with either our SEO values if present, otherwise passed in specifics let seoSettings = { - title: seo?.title || title ? `${title} ${META.titleAppend}` : '', + title: seo?.title || generatedTitle, description: seo?.metaDesc || description, openGraph: { - title: seo?.title || title, - description: seo?.opengraphDescription || seo?.metaDesc, + title: seo?.opengraphTitle || seo?.title || title, + description: + seo?.opengraphDescription || seo?.metaDesc || description, }, }; + // check for passed in image from SEO object or image param. Otherwise check for global fallback let imageUrl; - // check for passed in image from SEO object or image param. Otherwise check for global fallback if (seo?.opengraphImage?.sourceUrl || image) { - imageUrl = nextLoader({ - src: seo?.opengraphImage?.sourceUrl || image, - width: 1200, - height: 628, - }); + imageUrl = seo?.opengraphImage?.sourceUrl || image; } else if (globals?.seo.openGraph?.defaultImage?.sourceUrl) { - imageUrl = nextLoader({ - src: globals.seo.openGraph.defaultImage.sourceUrl, - width: 1200, - height: 628, - }); + imageUrl = globals.seo.openGraph.defaultImage.sourceUrl; } if (imageUrl) { seoSettings.openGraph.images = [ { - url: META.url + imageUrl, + url: imagePath(imageUrl), width: 1200, height: 628, }, ]; } - // defaults are fine, only set noindex or nofollow if explicit + // set noindex or nofollow if explicitly defined in Yoast if (seo?.metaRobotsNoindex == 'noindex') { seoSettings.noindex = true; } @@ -52,9 +69,6 @@ export default function Meta({ title, description, image, seo }) { seoSettings.nofollow = true; } - // console.log('seoSettings', JSON.stringify(seoSettings, null, 2)); - // console.log('seo', JSON.stringify(seo, null, 2)); - return ( <> @@ -65,14 +79,40 @@ export default function Meta({ title, description, image, seo }) { /> {/* favicons */} - {/* */} - - + {META.icon32 && ( + + )} + {META.iconApple && ( + + )} + {/* twitter specific meta if defined */} + {seo?.twitterTitle && ( + + )} + {seo?.twitterDescription && ( + + )} + {seo?.twitterImage?.sourceUrl && ( + + )} + {/* pass customized SEO object to NextSeo to render all other tags */} ); diff --git a/website/src/lib/constants.js b/website/src/lib/constants.js index 8273d41..844f03c 100644 --- a/website/src/lib/constants.js +++ b/website/src/lib/constants.js @@ -26,4 +26,7 @@ export const META = { url: 'https://bubs.patronage.org', twitterHandle: '@patronageorg', siteName: 'Bubs by Patronage', + proxyWordPressImages: true, + icon32: '', + iconApple: '/apple-touch-icon.png', };