Next.js(SSG)× Docker × TailwindCSSでPreview機能付きヘッドレスWordPressサイトを作ってみた

はじめに

完全独学でプログラミングを勉強し始めて1年強(実務経験6ヶ月)が立ったので何か形として残るものを作ろうと思いました(GW暇すぎてやることがなさすぎた・・・)。

現在はご縁を頂いたとある会社にてPHP・TypeScriptをメインにエンジニアとして働かせて頂いております。

自分自身、独学時代に顔も知らない諸先輩方のQiitaやZennの記事に大変助けられた経緯があり、お返しとまではいきませんが私も何かお役に立てれば幸いです。

Next × WordPressはVercelでも紹介されていますが、Docker上でNext × WordPressを構築し、かつネックとなるpreview機能も利用可能な実装例がネット上に無かったため試してみようと思ったのが背景です。

※歴も浅く至らない点多々あるのでご指摘あれば、ぜひコード例と編集リクエスト頂けると嬉しいです。

本記事では以下の形で進めていきます。

  • 実際に本アプリをローカルで動かすための環境構築を行う
  • 具体的なコードの紹介(説明は要点のみでかなり省略しております)

おねがい
動作確認してみたい方はコピペミスを防止するため、コードの転記ではななく環境構築編を進めてい頂ければと思います。

こんな感じで簡単な記事一覧 + 詳細 + 管理画面 + preview機能ができあがります。

画面収録 2022-05-15 8 31 25

githubはこちらになります。クローンされない場合はこちらを適宜ご覧ください。

https://qiita.com/embed-contents/link-card#qiita-embed-content__dcc9d35427dd2ca5b192ac7ed53d00d6

環境構築編:

リポジトリのクローン:

git clone git@github.com:WebEngrChild/next-wp-headless.git

(注意)M1Mac利用の場合は以下を修正:

package.json
  "dependencies": {
    "@heroicons/react": "^1.0.6",
    "@next/swc-linux-x64-gnu": "^11.1.2", // この行を削除
  ...
  }

Docker Container起動:

docker-compose up -d

WordPress初期設定:

localhost:8080アクセス後に以下を設定。

1. ユーザー登録

  • 必要情報を適宜入力
スクリーンショット 2022-05-07 10 10 22

2. RESTAPIの初期設定

  • パーマリンク設定を投稿名に変更:
スクリーンショット 2022-05-07 10 27 45
  • アプリケーションパスワードを設定:
    • パスワードは必ずコピーしておくこと。
スクリーンショット 2022-05-07 10 17 51

Next.jsフロント初期設定:

.env.localの作成:

cp .env.example .env.local 
# 上記で登録したユーザー名
WP_USER=
# 上記で登録したアプリケーションパスワード
WP_AP_PASS=

アプリケーション起動:

make start

WordPress管理画面:localhost:8080/admin

Next記事一覧画面:localhost:3030

Next記事詳細画面:localhost:3030

スクリーンショット 2022-05-14 8 19 04

プレビュー機能

wordpressの投稿一覧 or 記事修正画面のプレビューボタンを押下。

スクリーンショット 2022-05-14 8 33 52

Next.jsのフロントにリダイレクトされ編集内容が表示。

スクリーンショット 2022-05-14 8 40 13

コード紹介編:

/pages/index.tsx
import axios from 'axios';
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import { FC } from 'react';
import Body from '../components/Body';
import Title from '../components/Title';
import MainLayout from '../layouts';
import { WPPost } from '../libs/wpapi/interfaces';

export type Props = {
  posts: WPPost[];
};

const Home: FC<Props> = ({ posts }) => {
  return (
    <MainLayout>
      <div>
        <Head>
          <title>Next.jsとWordpressを使ったHeadlessCMS</title>
          <meta name='description' content='Generated by create next app' />
          <link rel='icon' href='/favicon.ico' />
        </Head>
        <div className='py-12 bg-white'>
          <div className='px-4 mx-auto max-w-7xl sm:px-6 lg:px-8'>
            <Title text='記事一覧' />
            <Body posts={posts} />
          </div>
        </div>
      </div>
    </MainLayout>
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const posts: WPPost[] = await axios.get(process.env.WP_URL!).then((response) => response.data);
  return { props: { posts } };
};

export default Home;
/pages/_app.tsx
import 'tailwindcss/tailwind.css';
import type { AppProps } from 'next/app';

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default MyApp;
/pages/preview/index.tsx
import axios from 'axios';
import { GetServerSideProps } from 'next';
import { WPPost } from '../../libs/wpapi/interfaces';
import Post from '../post/[id]';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const post_url = process.env.WP_URL! + context.query.id + '?_embed&status=draft';
  const post = await axios
    .get(post_url, {
      auth: {
        username: process.env.WP_USER!,
        password: process.env.WP_AP_PASS!,
      },
    })
    .then((response) => response.data);
  return { props: post };
};

const Preview = (post: WPPost) => {
  return post ? <Post post={post} /> : null;
};

export default Preview;

公開済みの記事と違い、未公開の下書き記事の場合は未認証でWordPressRESTAPIを利用することができません。したがってWordPress 5.6に導入されたアプリケーションパスワードを利用します。

パスワードに関してはすでに環境構築の際に設定済みです。

https://qiita.com/embed-contents/link-card#qiita-embed-content__198bb379cf610da1036339c1bbd8fb71

/pages/post/[id].tsx
import axios from 'axios';
import { GetStaticProps } from 'next';
import { FC } from 'react';
import Title from '../../components/Title';
import MainLayout from '../../layouts';
import { WPPost } from '../../libs/wpapi/interfaces';

export type Props = {
  post: WPPost;
};

const Post: FC<Props> = ({ post }) => {
  return (
    <MainLayout>
      <div className='py-12 bg-white'>
        <div className='px-4 mx-auto max-w-7xl sm:px-6 lg:px-8'>
          <Title text='記事詳細' />
          <h1
            className='m-5 mt-2 text-lg font-extrabold tracking-tight leading-8 text-gray-700 sm:text-3xl'
            dangerouslySetInnerHTML={{ __html: post.title.rendered }}
          ></h1>
          <p
            className='ml-2 text-lg font-medium leading-6 text-gray-700'
            dangerouslySetInnerHTML={{ __html: post.content.rendered }}
          ></p>
        </div>
      </div>
    </MainLayout>
  );
};

export default Post;

export const getStaticPaths = async () => {
  const posts: WPPost[] = await axios.get(process.env.WP_URL!).then((response) => response.data);
  const paths = posts.map((post) => ({
    params: { id: post.id.toString() },
  }));

  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post: WPPost = await axios
    .get(process.env.WP_URL! + params!.id)
    .then((response) => response.data);
  return { props: { post } };
};

WordPressAPIで取得した記事データをブラウザDOMにおけるinnerHTMLのReactでの代替であるdangerouslySetInnerHTMLを使って埋め込んでいます。

https://qiita.com/embed-contents/link-card#qiita-embed-content__a6725cd8d2df7737cb4928901398078a

/libs/wpapi/interfaces.ts
export type WPPostType = 'posts' | 'pages';
export type WPPost = {
  id: string;
  slug: string;
  title: {
    rendered: string;
  };
  content: {
    rendered: string;
  };
  excerpt: {
    rendered: string;
  };
  date: string;
  date_gmt: string;
  format: string;
  modified: string;
  modified_gmt: string;
  status: string;
  sticky: boolean;
  type: string;
  link: string;
  _embedded: WPPostEmbedded;
};
export type WPMediaDetailSize = {
  file: string;
  height: number;
  mime_type: string;
  source_url: string;
  witdh: number;
};
export type WPMediaDetailSizes = {
  medium: WPMediaDetailSize;
  full: WPMediaDetailSize;
  large: WPMediaDetailSize;
  medium_large: WPMediaDetailSize;
  thumbnail: WPMediaDetailSize;
};

export type WPPostEmbedded = {
  author: [
    {
      id: string;
      name: string;
      url: string;
      avatar_urls: {
        24: string;
        48: string;
        96: string;
      };
      description: string;
      link: string;
      slug: string;
    },
  ];
  'wp:featuredmedia'?: Array<{
    alt_text: string;
    author: number;
    caption: {
      rendered: string;
    };
    date: string;
    id: number;
    link: string;
    media_details: {
      width: number;
      height: number;
      file: string;
      image_meta: {
        [key: string]: string;
      };
      sizes: WPMediaDetailSizes;
    };
    media_type: string;
    mime_type: string;
    slug: string;
    source_url: string;
    title: {
      rendered: string;
    };
    type: string;
  }>;
  'wp:term': [
    [
      {
        id: string;
        link: string;
        name: string;
        slug: string;
        taxonomy: string;
      },
    ],
  ];
};

WodpressAPIのデータ型定義に関しては以下サイトを参考にさせていただきました。
今回はタイトルと本文のみですが上記の通り他にも様々なデータを取得できます。

https://qiita.com/embed-contents/link-card#qiita-embed-content__526e5fbae5f9b9ec8f1a98fff8fdea02

/wordpress/wp-content/themes/twentytwentytwo/functions.php
<?php
// ファイル内の一部のみ抜粋

// previewボタン押下時にnext側にリダイレクト
add_action("template_redirect", function () {
	if (!is_admin() && isset($_GET["preview"]) && $_GET["preview"] == true) {
		$redirect = add_query_arg(
			[
				"id" => $_GET["preview_id"] ? $_GET["preview_id"] : $_GET["p"],
			],
			"http://localhost:3030/preview"
		);
		wp_redirect($redirect);
	}
});

// application password有効化
add_filter( 'wp_is_application_passwords_available', '__return_true' );

WordPress側の管理画面ページのpreviewボタン押下時にNext側にリダイレクトがされるように追記します。add_query_argでURLにパラメーターを再構築してwp_redirect()で遷移させます。

また、デフォルトではapplication passwordは有効化されていないためadd_filterフックを利用して有効化します。

https://qiita.com/embed-contents/link-card#qiita-embed-content__8c587bed959f9cae3205bbc955e48d06

https://qiita.com/embed-contents/link-card#qiita-embed-content__c255838f8e84521278b80084fab398ff

/layouts/index.tsx
import Nav from '../components/Nav';

type LayoutProps = {
  children: React.ReactNode;
};

function MainLayout({ children }: LayoutProps): JSX.Element {
  return (
    <>
      <Nav />
      <main>{children}</main>
    </>
  );
}

export default MainLayout;
/components/Title.tsx
import { FC } from 'react';

type Title = {
  text: string;
};

const Title: FC<Title> = ({ text }) => {
  return (
    <>
      <div className='lg:text-center'>
        <h2 className='text-base font-semibold tracking-wide text-indigo-600 uppercase'>
          Let&lsquo;s try it
        </h2>
        <p className='mt-2 text-3xl font-extrabold tracking-tight leading-8 text-gray-900 sm:text-4xl'>
          {text}
        </p>
      </div>
    </>
  );
};

export default Title;
/components/Nav.tsx
import Link from 'next/link';

const Nav = () => {
  return (
    <>
      <nav className='flex flex-wrap justify-between items-center p-6 bg-indigo-500'>
        <div className='flex shrink-0 items-center mr-6 text-white'>
          <span className='text-xl font-semibold tracking-tight'>サンプルブログ</span>
        </div>
        <div className='block grow w-full lg:flex lg:items-center lg:w-auto'>
          <div className='text-sm lg:grow'>
            <Link href='/' passHref>
              <a href=''>
                <div className='block mt-4 mr-4 text-indigo-200 hover:text-white lg:inline-block lg:mt-0'>
                  記事一覧
                </div>
              </a>
            </Link>
          </div>
        </div>
      </nav>
    </>
  );
};

export default Nav;
/components/Body.tsx
import { DocumentTextIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { WPPost } from '../libs/wpapi/interfaces';

export type Props = {
  posts: WPPost[];
};

const Body: React.FC<Props> = ({ posts }) => {
  return (
    <div className='mt-10'>
      <dl className='space-y-10 md:grid md:grid-cols-2 md:gap-x-8 md:gap-y-10 md:space-y-0'>
        {posts.map((post) => (
          <Link key={post.title.rendered} href={`/post/${post.id}`}>
            <a href=''>
              <div className='relative'>
                <dt>
                  <div className='flex absolute justify-center items-center w-12 h-12 text-white bg-indigo-500 rounded-md'>
                    <DocumentTextIcon className='w-6 h-6' aria-hidden='true' />
                  </div>
                  <p
                    className='ml-16 text-lg font-medium leading-6 text-gray-900'
                    dangerouslySetInnerHTML={{ __html: post.title.rendered }}
                  ></p>
                </dt>
                <dd
                  className='mt-2 ml-16 text-base text-gray-500'
                  dangerouslySetInnerHTML={{ __html: post.content.rendered }}
                ></dd>
              </div>
            </a>
          </Link>
        ))}
      </dl>
    </div>
  );
};

export default Body;
/.docker/front/Dockerfile
FROM node:14.17-alpine

RUN apk update
RUN apk add curl

ENV TZ Asia/Tokyo
ENV PATH $HOME/.yarn/bin:$HOME/.config/yarn/global/node_modules/.bin:$PATH

WORKDIR /usr/src/app
USER node
/.docker/wp/Dockerfile
FROM wordpress:latest

RUN apt-get update && apt-get install -y \
vim

開発中はWordpressコンテナで色々と動作検証を行うためにコマンドインストール用にDockerfileを切りましたがdocker-compose.ymlに直書きしても問題ありません。

/docker-compose.yml
version: '3'

services:
   db:
     image: mysql:5.7
     volumes:
       - db_data:/var/lib/mysql
     restart: always
     environment:
       MYSQL_ROOT_PASSWORD: somewordpress
       MYSQL_DATABASE: wordpress
       MYSQL_USER: wordpress
       MYSQL_PASSWORD: wordpress
     networks:
      - wp_next

   wordpress:
     build:
      context: ./.docker/wp
      dockerfile: Dockerfile
     container_name: wordpress
     depends_on:
       - db
     ports:
       - "8080:80"
     restart: always
     environment:
       WORDPRESS_DB_HOST: db:3306
       WORDPRESS_DB_USER: wordpress
       WORDPRESS_DB_PASSWORD: wordpress
     volumes:
       - ./wordpress:/var/www/html
     networks:
      - wp_next

   front:
     build:
      context: ./.docker/front
      dockerfile: Dockerfile
     volumes:
       - ./:/usr/src/app
     stdin_open: true
     tty: true
     ports:
       - "3030:3000"
     networks:
      - wp_next

volumes:
    db_data:

networks:
  wp_next:
    driver: bridge

WordPressとNextを動かしているコンテナ間の疎通ができるようにwp_nextというネットワークを作成しています。また、こちらを定義することでhttp://wordpress/wp-json/wp/v2/posts/といったようにコンテ名で名前解決が可能になります。

https://qiita.com/embed-contents/link-card#qiita-embed-content__a09b70cb17c61c9490cd5d708ffff88d

Makefile

start:
	docker-compose up -d
	docker-compose exec front yarn
	docker-compose exec front yarn serve

# Docker
up:
	docker-compose up -d
	docker-compose exec front yarn serve

down:
	docker-compose down

build:
	docker-compose build --no-cache

# Next
front:
	docker-compose exec front sh

dev:
	docker-compose exec front yarn dev

serve:
	docker-compose exec front yarn serve

fix:
	docker-compose exec front yarn fix

# WordPress
wp:
	docker compose exec wordpress bash

$make hogeという形で長ったらしいコマンドを省略可能です。

https://qiita.com/embed-contents/link-card#qiita-embed-content__6d89b28de119a42d570a87808a24a8af

.eslintrc.json
// .eslintrc
{
  "extends": [
    "next",
    "next/core-web-vitals",
    "prettier",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "plugin:import/warnings",
    "plugin:tailwindcss/recommended"
  ],
  "ignorePatterns": ["*.config.js"],
  "rules": {
    "import/order": [
      "error",
      {
        "alphabetize": {
          "order": "asc"
        }
      }
    ]
  }
}

業務でも活用していますがESLint と Prettierを併用してコードを綺麗に保つようにしました。以下記事が本当に分かりやすくためになるのでおすすめです。

https://qiita.com/embed-contents/link-card#qiita-embed-content__039276a58bc05186a52caedf5bb1e67d

.prettierignore

.next/*
node_modules/*
out/*
wordpress/*

prettierの自動整形で対象外としたいディレクトリを指定しています。.gitignoreと書き方は同じです。今回で存在を初めてしりました。

package.json
{
  "name": "next-wp-headless",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "serve": "next build && next start",
    "fix": "run-s fix:*",
    "fix:lint": "next lint --fix",
    "fix:prettier": "prettier --write './**/*.{js,jsx,ts,tsx,json,css}'"
  },
  "dependencies": {
    "@heroicons/react": "^1.0.6",
    "@next/swc-linux-x64-gnu": "^11.1.2",
    "axios": "^0.27.2",
    "next": "12.1.5",
    "react": "18.1.0",
    "react-dom": "18.1.0"
  },
  "devDependencies": {
    "@types/node": "17.0.31",
    "@types/react": "18.0.8",
    "@types/react-dom": "18.0.3",
    "autoprefixer": "^10.4.7",
    "eslint": "8.14.0",
    "eslint-config-next": "12.1.5",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-tailwindcss": "^3.5.0",
    "postcss": "^8.4.13",
    "prettier": "^2.6.2",
    "serve": "^13.0.2",
    "tailwindcss": "^3.0.24",
    "typescript": "4.6.4",
    "yarn-run-all": "^3.1.1"
  },
  "prettier": {
    "trailingComma": "all",
    "tabWidth": 2,
    "semi": true,
    "singleQuote": true,
    "jsxSingleQuote": true,
    "printWidth": 100
  }
}

lintとprettierを同コマンドで一発に走らせるために以下のライブラリを利用しました。本アプリではmake fixで利用可能です。

https://qiita.com/embed-contents/link-card#qiita-embed-content__5c9fec558e1355b0aeaac826477ecfae

環境構築編で既述ですが、M1Macでは以下は不要になります。

"@next/swc-linux-x64-gnu": "^11.1.2",

NextではRustベースの高速なコンパイラであるSWCを利用しており、
かつシステムに固有の互換性のあるバイナリをダウンロードする必要があるとのことです。

私は自宅ではかなり古いIntelMacを使用して開発を行なっているのですが、業務で利用しているM1Macで動かしてみて発覚しました。

ホストマシン(M1・Intel)によってCPUアーキテクチャとの互換性が合わなかったりするのか・・・?とか考えながらも難しいぃいとなりました。

また、ホスト側とDocker側でそれぞれyarnを行った場合に、
node_modules/.yarn-integrity内の"systemParams""linux-arm64-93"からdarwin-arm64-93に変更されている(M1mac)とのことでホストとDocker間の差異もあったりするのかな・・?。とか考えて結局分かっていません。泣

https://qiita.com/embed-contents/link-card#qiita-embed-content__ae2a45661708e9206880dc3a8a1f05b1

https://qiita.com/embed-contents/link-card#qiita-embed-content__2498b5205b26d8af8be4ceb9207020ff

おわりに

開発期間はGW中と短期間ではありましたが自分にとっても新しい気づきが多く有意義な時間でした。特にWordPressAPI・アプリケーションパスワードを使ったSPAでの認可はネットにも情報が少なくかなり苦労しました。

続編も検討しており、本アプリをAWSにデプロイする方法も記事としてまとめる予定です・・・!