Next.js & Wordpress

Anh-Thi Dinh
⚠️
This note contains only the "difficult" parts when creating a Next.js website from Wordpress. You should read the official documetation and also things mentioned in this note (it's about Gatsby and WP).
I prefer Next.js other than Gatsby when using with Wordpress because I want to use for free the "incremental building" functionality. As I know, if we want something like that with Gatsby, we have to use Gatsby Cloud (paid tiers).

Getting started

👉 IMPORTANT: Use this official starter (it uses TypeScript, Tailwind CSS).
⚠️
The following steps are heavily based on this starter, which means that some packages/settings have already been set up by this starter.
Using Local for a local version of Wordpress (WP) site. Read more in this blog. From now, I use math2it.local for a local WP website in this note and math2it.com for its production version.
In WP, install and activate plugin WPGraphQL.
Copy .env.local.example to .env.local and change it content.
1npm i
2npm run dev
The site is running at http://localhost:3000.

Basic understanding: How it works?

How pages are created?
  • pages/abc.tsx leads to locahost:3000/abc
  • pages/xyz/abc.tsx leads to localhost:3000/xyz/abc
  • pages/[slug].tsx leads to localhost:3000/<some-thing>. In this file,
    • We need getStaticPaths to generates all the post urls.
    • We need getStaticProps to generate the props for the page template. In this props, we get the data of the post which is going to be created! How? It gets the params.slug from the URL (which comes from the pattern pages/[slug].tsx). We use this params to get the post data. The [slug].tsx helps us catch the url's things. Read more about the routing.
    • Note that, all neccessary pages will be created before deploying (that why we call it static)

Vercel CLI

Build and run vercel environment locally before pushing to the remote.
1npm i -g vercel
Link to the current project
1vercel dev
2# and then choose the corresponding options
Run build locally,
1vercel build

Styling

SCSS / SASS

1npm install --save-dev sass
Define /styles/main.scss and import it in _app.tsx
1import '../styles/main.scss'

Work with Tailwind

✳️ Define a new class,
1// in a css file
2@layer components {
3  .thi-bg {
4    @apply bg-white dark:bg-main-dark-bg;
5  }
6}
Use as: className="thi-bg"
✳️ Define a new color,
1// tailwind.config.js
2module.exports = {
3 theme: {
4   extend: {
5     colors: {
6       main: '#1e293b'
7     }
8   }
9 }
10}
Use as: className="text-main bg-main"
✳️ Custom and dynamic colors,
1// './src/styles/safelist.txt'
2bg-[#1e293b]
1// tailwind.config.js
2module.exports = {
3  content: [
4    './src/styles/safelist.txt'
5  ]
6}
Use as: className="bg-[#1e293b]"

Preview mode

Check this doc and the following instructions.
Install and activate WP plugin wp-graphql-jwt-authentication.
Modify wp-config.php
1define( 'GRAPHQL_JWT_AUTH_SECRET_KEY', 'daylamotkeybimat' );
Get a refresh token with GraphiQL IDE (at WP Admin > GraphQL > GraphiQL IDE),
1mutation Login {
2  login(
3    input: {
4      clientMutationId: "uniqueId"
5      password: "your_password"
6      username: "your_username"
7    }
8  ) {
9  	refreshToken
10  }
11}
Modify .env.local
1WORDPRESS_AUTH_REFRESH_TOKEN="..."
2WORDPRESS_PREVIEW_SECRET='daylamotkeybimat' # the same with the one in wp-config.php
Link the preview (id is the id of the post, you can find it in the post list).
1# "daylamotkeybimat" is the same as WORDPRESS_PREVIEW_SECRET
2<http://localhost:3000/api/preview?secret=daylamotkeybimat&id=12069>
⚠️
It may not work with math2it.local but math2it.com (the production version).

Dev environment

✳️ VSCode + ESLint + Prettier.
Follow instructions in Build a website with Wordpress and Gatsby (part 1) with additional things
1// Add to .eslintrc
2{
3  rules: {},
4	extends: ['next'],
5	ignorePatterns: ['next-env.d.ts']
6}
🐝 Error: Failed to load config "next" to extend from?
1npm i -D eslint-config-next
✳️ Problem Unknown at rule @apply when using TailwindCSS.
Add the folloing settings in the VSCode settings,
1"css.validate": false, // used for @tailwindcss
2"scss.validate": false, // used for @tailwindcss,

Troubleshooting after confuguring

✳️ 'React' must be in scope when using JSX
1// Add this line to the top of the file
2import React from 'react';
✳️ ...is missing in props validation
1// Before
2export default function Layout({ preview, children }) {}
3
4// After
5type LayoutProps = { preview: boolean; children: React.ReactNode }
6export default function Layout(props: LayoutProps) {
7  const { preview, children } = props
8}

Prettier things

1npm install --save-dev @trivago/prettier-plugin-sort-imports
2npm install -D prettier prettier-plugin-tailwindcss
In .prettierrc
1{
2  "plugins": [
3    "./node_modules/@trivago/prettier-plugin-sort-imports",
4    "./node_modules/prettier-plugin-tailwindcss"
5  ],
6  "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"],
7  "importOrderSeparation": true,
8  "importOrderSortSpecifiers": true
9}

Check ESLint server

There are some rules taking longer than the others. Use below command to see who they are.
1TIMING=1 npx eslint lib
2Rule                                   | Time (ms) | Relative
3:--------------------------------------|----------:|--------:
4tailwindcss/no-custom-classname        |  6573.680 |    91.0%
5prettier/prettier                      |   543.009 |     7.5%
If you wanna turn off some rules (check more options),
1// .eslintrc.js
2{
3  rules: {
4    'tailwindcss/no-custom-classname': 'off'
5  }
6}
Run TIMING=1 npx eslint lib again to check!

Types for GraphQL queries

⚠️
Update: I decide to use self-defined types for what I use in the project.
We use GraphQL Code Generator (or codegen). Read the official doc.

Install codegen

1npm install graphql
2npm install -D typescript
3npm install -D @graphql-codegen/cli
1npx graphql-code-generator init
Below are my answers,
1? What type of application are you building? Application built with React
2? Where is your schema?: (path or url) <http://math2it.local/graphql>
3? Where are your operations and fragments?: graphql/**/*.graphql
4? Where to write the output: graphql/gql
5? Do you want to generate an introspection file? No
6? How to name the config file? codegen.ts
7? What script in package.json should run the codegen? generate-types
1# install the chosen packages
2npm install

Use codegen with env variable

Want to use next.js's environment variable (.env.local) in the codegen's config file? Add the following codes to codegen.ts
1import { loadEnvConfig } from '@next/env'
2loadEnvConfig(process.cwd())
3
4// then you can use
5const config: CodegenConfig = {
6  schema: process.env.WORDPRESS_API_URL,
7}

Generate types

Generate the types,
1npm run generate-types
If you want to run in watch mode aloside with npm run dev
1npm i -D concurrently
Then modify package.json
1{
2  "scripts": [
3    "dev": "concurrently \\"next\\" \\"npm run generate-types-watch\\" -c green,yellow -n next,codegen",
4    "generate-types-watch": "graphql-codegen --watch --config codegen.ts"
5  ]
6}
Now, just use npm run dev for both. What you see is something like this

Usage

For example, you want to query categories from http://math2it.local/graphql with,
1"""file: graphql/categories.graphql"""
2query Categories {
3  categories {
4    edges {
5      node {
6        name
7      }
8    }
9  }
10}
After run the generate code, we have type CategoriesQuery in graphql/gql/graphql.ts (==the name of the type is in the formulas <nameOfQuery>Query==). You can import this type in a .tsx component as follow,
1// components/categories.tsx
2import { CategoriesQuery } from '../graphql/gql/graphql'
3
4export default function Categories(props: CategoriesQuery) {
5  const { categories } = props
6	return (
7  	{ categories.edges.map(...) }
8  )
9}

Make codegen recognize query in lib/api.ts

Add /* GraphQL */ before the query string! Read more in the official doc for other use cases and options!
1// From this
2const data = await fetchAPI(`
3	query PreviewPost($id: ID!, $idType: PostIdType!) {
4    post(id: $id, idType: $idType) {
5      databaseId
6      slug
7      status
8    }
9	}`
10)
1// To this
2const data = await fetchAPI(
3  /* GraphQL */ `
4	query PreviewPost($id: ID!, $idType: PostIdType!) {
5    post(id: $id, idType: $idType) {
6      databaseId
7      slug
8      status
9    }
10	}`
11)

Use with Apollo Client

The starter we use from the beginning of this note is using typescript's built-in fetch() to get the data from WP GraphQL (check the content of lib/api.ts). You can use Apollo Client instead.
1npm install @apollo/client graphql
1// Modify lib/api.ts
2const client = new ApolloClient({
3  uri: process.env.WORDPRESS_API_URL,
4  cache: new InMemoryCache(),
5})
6
7async function fetchAPI() {
8  export async function getAllPostsForHome() {
9    const { data } = await client.query({
10      query: gql`
11        query AllPosts {
12          posts(first: 20, where: { orderby: { field: DATE, order: DESC } }) {
13            edges {
14              node {
15                title
16                excerpt
17              }
18            }
19          }
20        }
21      `,
22    })
23  	return data?.posts
24  }
25}
1// pages/index.tsx
2import { getAllPostsForHome } from '../lib/api'
3
4export default function Index({ allPosts: { edges } }) {
5  return ()
6}
7
8export const getStaticProps: GetStaticProps = async () => {
9  const allPosts = await getAllPostsForHome()
10  return {
11    props: { allPosts },
12    revalidate: 10,
13  }
14}

GraphQL things

✳️ Query different queries in a single query with alias,
1query getPosts {
2  posts: posts(type: "POST") {
3    id
4		title
5  }
6  comments: posts(type: "COMMENT") {
7    id
8		title
9  }
10}
Otherwise, we get an error Fields 'posts' conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.

Images

Local images,
1import profilePic from '../public/me.png'
2<Image
3  src={profilePic}
4  alt="Picture of the author"
5/>
Inside an <a> tag?
1<Link href={`/posts/${slug}`} passHref>
2  <a aria-label={title}>
3  	<Image />
4  </a>
5</Link>
I use below codes,
(To apply "blur" placeholder effect for external images, we use plaiceholder)
1<!-- External images -->
2<div style="position: relative;">
3	<Image
4    alt={imageAlt}
5    src={featuredImage?.sourceUrl}
6    className={imageClassName}
7    fill={true} // requires father having position "relative"
8    sizes={featuredImage?.sizes || '100vw'} // required
9    placeholder={placeholderTouse}
10    blurDataURL={blurDataURLToUse}
11	/>
12</div>
13
14<!-- Internal images -->
15<Image
16  alt={imageAlt}
17  src={defaultFeaturedImage}
18  className={imageClassName}
19  priority={true}
20/>
⚠️
Remarks:
  • If you use fill={true} for Image, its parent must have position "relative" or "fixed" or "absolute"!
  • If you use plaiceholder, the building time takes longer than usual. For my site, after applying , the building time increases from 1m30s to 2m30s on vercel!
🐝 Invalid next.config.js options detected: The value at .images.remotePatterns[0].port must be 1 character or more but it was 0 characters. → Remove port: ''!

Loading placeholder div for images

We use only the CSS for the placeholder image. We gain the loading time and also the building time for this idea!
If you wanna add a div (with loading effect CSS only).
1// In the component containing <Image>
2import Image from 'next/image'
3import { useState } from 'react'
4
5export default function ImageForPost({ title, featuredImage, blurDataURL, categories }) {
6  const [isImageReady, setIsImageReady] = useState(false)
7  const onLoadCallBack = () => {
8    setIsImageReady(true)
9  }
10  const image = (
11  	<Image
12      alt={imageAlt}
13      src={externalImgSrc}
14      className={imageClassName}
15      fill={true}
16      sizes={externalImgSizes || '100vw'}
17      onLoadingComplete={onLoadCallBack}
18    />
19  )
20  return (
21  	<>
22      <div className="block h-full w-full md:animate-fadeIn">{image}</div>
23    	{!isImageReady && (
24        <div className="absolute top-0 left-0 h-full w-full">
25          <div className="relative h-full w-full animate-pulse rounded-lg bg-slate-200">
26            <div className="absolute left-[14%] top-[30%] z-20 aspect-square h-[40%] rounded-full bg-slate-300"></div>
27            <div className="absolute bottom-0 left-0 z-10 h-2/5 w-full bg-wave"></div>
28          </div>
29        </div>
30  		)}
31    </>
32}

Custom fonts

Some remarks:
  • Using display=optional
    • 1<https://fonts.googleapis.com/css2?family=Krona+One&display=optional>"
  • Add Google font to pages/_document.js, nextjs will handle the rest.
Next.js currently supports optimizing Google Fonts and Typekit.s

Routes

Catch all routes: pages/post/[...slug].js matches /post/a, but also /post/a/b, /post/a/b/c and so on.
Optional catch all routes: pages/post/[[...slug]].js will match /post, /post/a, /post/a/b, and so on.
The order: predefined routes > dynamic routes > catch all routes

Components

Fetch menu data from WP and use it for navigation component? Read this for an idea. Note that, this data is fetched on the client-side using Vercel's SWR.
I create a constant MENUS which defines all the links for the navigation. I don't want to fetch on the client side for this fixed menu.
Different classes for currently active menu?
1import cn from 'classnames'
2import { useRouter } from 'next/router'
3
4export default async function Navigation() {
5  const router = useRouter()
6  const currentRoute = router.pathname
7	return (
8  	{menus?.map((item: MenuItem) => (
9      <Link key={item?.id} href={item?.url as string}>
10        <a
11          className={isActiveClass(
12            item?.url === currentRoute
13          )}
14          aria-current={
15            item?.url === currentRoute ? 'page' : undefined
16          }
17          >
18          {item?.label}
19        </a>
20      </Link>
21    ))}
22  )
23}
24
25const isActiveClass = (isCurrent: boolean) =>
26	cn(
27  	'fixed-clasess',
28    { 'is-current': isCurrent, 'not-current': !isCurrent }
29  )
Remark: router's pathname has a weekness when using with dynamic routes, check this for other solutions.

Taxonomy pages

URL format from Wordpress: /category/math/ or /category/math/page/2/ 👉 Create /pages/category/[[...slug]].tsx (Read more about optional catching all routes)
To query posts with "offset" and "limit", use this plugin.
Remark: When querying with graphql, $tagId gets type String whereas $categoryId gets type Int!

Search

If you need: when submitting the form, the site will navigate to the search page at /search/?s=query-string, use router.push()!
1import { useRef, useState } from 'react'
2import { useRouter } from 'next/router'
3
4export default function Navigation() {
5	const router = useRouter()
6  const [valueSearch, setValueSearch] = useState('')
7  const searchInput = useRef(null)
8  return (
9  	<form onSubmit={e => {
10      e.preventDefault()
11      router.push(`/search/?s=${encodeURI(valueSearch)}`)
12    }}
13    >
14    	<button type="submit">Search</button>
15      <input
16        type="search"
17        value={valueSearch}
18        ref={searchInput}
19        onChange={e => setValueSearch(e.target.value)}
20      />
21    </form>
22  )
23}
👉 Read more: SWR data fetching.
👉 Read more:
Client-side data fetching.
1import React from 'react'
2import Layout from '../components/layout'
3import useSWR from 'swr'
4import Link from 'next/link'
5
6export default function SearchPage() {
7  const isBrowser = () => typeof window !== 'undefined'
8  let query = ''
9  if (isBrowser()) {
10    const { search } = window.location
11    query = new URLSearchParams(search).get('s') as string
12  }
13  const finalSearchTerm = decodeURI(query as string)
14  const { data, error } = useSWR(
15    `
16      {
17        posts(where: { search: "` +
18      finalSearchTerm +
19      `" }) {
20          nodes {
21            id
22            title
23            uri
24          }
25        }
26      }
27    `,
28    fetcher
29  )
30
31  return (
32    <Layout>
33      <div className="pt-20 px-8">
34        <div className="text-3xl">This is the search page!</div>
35        <div className="mt-8">
36          {error && <div className="pt-20">Failed to load</div>}
37          {!data && <div className="pt-20">Loading...</div>}
38          {data && (
39            <ul className="mb-6 list-disc pl-5">
40              {data?.posts?.nodes.map(node => (
41                <li key={node.id}>
42                  <Link href={node.uri}>
43                    <a>{node.title}</a>
44                  </Link>
45                </li>
46              ))}
47            </ul>
48          )}
49        </div>
50      </div>
51    </Layout>
52  )
53}
54
55const fetcher = async query => {
56  const headers: { [key: string]: string } = {
57    'Content-Type': 'application/json',
58  }
59  const res = await fetch('<http://math2it.local/graphql>', {
60    headers,
61    method: 'POST',
62    body: JSON.stringify({
63      query,
64    }),
65  })
66  const json = await res.json()
67  return json.data
68}

General troubleshooting

✳️ Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Have this when using
1const image = (<Image />)
2// then
3<Link href="">{image}</Link>
Wrap it with <a> tag,
1<Link href=""><a>{image}</a></Link>
🐝 If you use <a>, a linting problem The href attribute is required for an anchor to be keyboard accessible....
1<!-- Try <button> instead of <a> -->
2<Link href=""><button>{image}</button></Link>
Or add below rule to .eslintrc (not that, estlint detects the problem but it's still valid from the accessibility point of view ). Source.
1rules: {
2  'jsx-a11y/anchor-is-valid': [
3    'error',
4    {
5      components: ['Link'],
6      specialLink: ['hrefLeft', 'hrefRight'],
7      aspects: ['invalidHref', 'preferButton'],
8    },
9  ],
10}
👉 Read more in the official tut.
✳️ Warning: A title element received an array with more than 1 element as children.
1<!-- Instead of -->
2<title>Next.js Blog Example with {CMS_NAME}</title>
3
4<!-- Use -->
5const title = `Next.js Blog Example with ${CMS_NAME}`
6<title>{title}</title>
Read this answer to understand the problem.
✳️ Element implicitly has an 'any' type because expression of type '"Authorization"' can't be used to index type '{ 'Content-Type': string; }'.
1// This will fail typecheck
2const headers = { 'Content-Type': 'application/json' }
3headers['Authorization'] = `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`
4
5// This will pass typecheck
6const headers: { [key: string]: string } = { 'Content-Type': 'application/json' }
7headers['Authorization'] = `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`
8
✳️ (Codegen's error) [FAILED] Syntax Error: Expected Name, found ")".
1// Instead of
2query PostBySlug($id: ID!, $idType: PostIdType!) {
3  post(id: $id, idType: $idType) {
4
5// You have
6query PostBySlug($id: ID!, $idType: PostIdType!) {
7  post() { // <- here!!!
✳️ You cannot define a route with the same specificity as a optional catch-all route ("/choice" and "/choice\[\[...slug\]\]").
It's because you have both /pages/choice/[[slug]].tsx and /pages/choice.tsx. Removing one of two fixes the problem.
✳️ Could not find declaration file for module 'lodash'
1npm i --save-dev @types/lodash
✳️ TypeError: Cannot destructure property 'auth' of 'urlObj' as it is undefined.
Read this: prerender-error | Next.js. For my personal case, set fallback: false in getStaticPath() and I encountered also the problem of trailing slash. On the original WP, I use /about/ instead of /about. Add trailingSlash: true to the next.config.js fixes the problem.
✳️ Error: Failed prop type: The prop href expects a string or object in <Link>, but got undefined instead.
There are some Links getting href an undefined. Find and fix them or use,
1<Link href={value ?? ''}>...</Link>
✳️ Restore to the previous position of the page when clicking the back button (Scroll Restoration)
1// next.config.js
2module.exports = {
3	experimental: {
4    scrollRestoration: true,
5  },
6}
Remark: You have to restart the dev server.

References