はじめに
完全独学でプログラミングを勉強し始めて1年強(実務経験6ヶ月)が立ったので何か形として残るものを作ろうと思いました(GW暇すぎてやることがなさすぎた・・・)。
現在はご縁を頂いたとある会社にてPHP・TypeScriptをメインにエンジニアとして働かせて頂いております。
自分自身、独学時代に顔も知らない諸先輩方のQiitaやZennの記事に大変助けられた経緯があり、お返しとまではいきませんが私も何かお役に立てれば幸いです。
Next × WordPressはVercelでも紹介されていますが、Docker上でNext × WordPressを構築し、かつネックとなるpreview機能も利用可能な実装例がネット上に無かったため試してみようと思ったのが背景です。
※歴も浅く至らない点多々あるのでご指摘あれば、ぜひコード例と編集リクエスト頂けると嬉しいです。
本記事では以下の形で進めていきます。
- 実際に本アプリをローカルで動かすための環境構築を行う
- 具体的なコードの紹介(説明は要点のみでかなり省略しております)
おねがい
動作確認してみたい方はコピペミスを防止するため、コードの転記ではななく環境構築編を進めてい頂ければと思います。
こんな感じで簡単な記事一覧 + 詳細 + 管理画面 + preview機能ができあがります。
githubはこちらになります。クローンされない場合はこちらを適宜ご覧ください。
環境構築編:
リポジトリのクローン:
git clone git@github.com:WebEngrChild/next-wp-headless.git
(注意)M1Mac利用の場合は以下を修正:
"dependencies": {
"@heroicons/react": "^1.0.6",
"@next/swc-linux-x64-gnu": "^11.1.2", // この行を削除
...
}
Docker Container起動:
docker-compose up -d
Wordpress初期設定:
localhost:8080
アクセス後に以下を設定。
1. ユーザー登録
- 必要情報を適宜入力
2. RESTAPIの初期設定
- パーマリンク設定を投稿名に変更:
- アプリケーションパスワードを設定:
- パスワードは必ずコピーしておくこと。
Next.jsフロント初期設定:
.env.local
の作成:
cp .env.example .env.local
# 上記で登録したユーザー名
WP_USER=
# 上記で登録したアプリケーションパスワード
WP_AP_PASS=
アプリケーション起動:
make start
Wordpress管理画面:localhost:8080/admin
Next記事詳細画面:localhost:3030
プレビュー機能
wordpressの投稿一覧 or 記事修正画面のプレビューボタンを押下。
Next.jsのフロントにリダイレクトされ編集内容が表示。
コード紹介編:
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;
import 'tailwindcss/tailwind.css';
import type { AppProps } from 'next/app';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
export default MyApp;
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に導入されたアプリケーションパスワードを利用します。
パスワードに関してはすでに環境構築の際に設定済みです。
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
を使って埋め込んでいます。
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のデータ型定義に関しては以下サイトを参考にさせていただきました。
今回はタイトルと本文のみですが上記の通り他にも様々なデータを取得できます。
<?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
フックを利用して有効化します。
import Nav from '../components/Nav';
type LayoutProps = {
children: React.ReactNode;
};
function MainLayout({ children }: LayoutProps): JSX.Element {
return (
<>
<Nav />
<main>{children}</main>
</>
);
}
export default MainLayout;
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‘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;
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;
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;
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
FROM wordpress:latest
RUN apt-get update && apt-get install -y \
vim
開発中はWordpressコンテナで色々と動作検証を行うためにコマンドインストール用にDockerfileを切りましたが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/
といったようにコンテ名で名前解決が可能になります。
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
という形で長ったらしいコマンドを省略可能です。
// .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を併用してコードを綺麗に保つようにしました。以下記事が本当に分かりやすくためになるのでおすすめです。
.next/*
node_modules/*
out/*
wordpress/*
prettierの自動整形で対象外としたいディレクトリを指定しています。.gitignoreと書き方は同じです。今回で存在を初めてしりました。
{
"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
で利用可能です。
環境構築編で既述ですが、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間の差異もあったりするのかな・・?。とか考えて結局分かっていません。泣
おわりに
開発期間はGW中と短期間ではありましたが自分にとっても新しい気づきが多く有意義な時間でした。特にWordPressAPI・アプリケーションパスワードを使ったSPAでの認可はネットにも情報が少なくかなり苦労しました。
続編も検討しており、本アプリをAWSにデプロイする方法も記事としてまとめる予定です・・・!
今後も引き続きアウトプットを進めていこうと思います。