Next.jsでクライアントコンポーネントを利用して、プレビュー環境を作成する

最終更新日:

Table of contents

このチュートリアルでは、Next.jsでクライアントコンポーネントを利用して、プレビュー環境を作成する方法を紹介します。特に Static Exports を利用されている場合に有効です。Static Exportsを利用されていない場合(SSR環境が利用可能な場合)は、Next.jsのDraft Modeを利用して、プレビュー環境を作成する の記事をご確認ください。

記事内で使用している主なソフトウェアのバージョン

  • Next.js(next): 14.2.3
  • newt-client-js(newt-client-js): 3.3.2

前提条件

  1. Next.jsの App Router を利用していること
  2. 作成したサイトがデプロイ済みであること
  3. NewtのJS SDKである newt-client-js の基本的な利用方法について理解していること

Next.jsのセットアップについて知りたい場合は、以下のドキュメントをご確認ください。

概要

Next.jsのクライアントコンポーネントを利用して、プレビュー用のページにアクセスした時に、プレビューデータを取得できるようにします。
また、Newtのコンテンツ編集画面から、作成したプレビュー環境にアクセスできるようにします。

nextjs-preview-static1.jpg

ここでは、以下の流れでプレビュー処理を行うものとして、実装を進めていきます。

  • Newtの管理画面から「プレビュー」ボタンをクリックする
  • プレビュー用のパス /articles/draft に、secretslug のクエリパラメータをつけてアクセスする
  • secretslug の値を検証し、問題なければプレビューデータ(下書きデータ)を取得して表示する
この記事の実装では、APIのTokenがフロントエンドに露出します。セキュリティに問題がないか、十分注意するようお願いいたします。

ここでは NewtとNext.jsを利用してブログを作成する で作成したブログに対して、プレビュー設定を追加する方法を紹介します。
もし設定したいパスが異なる場合は、適宜読み替えながらチュートリアルを進めてください。

Next.js(App Router)でプレビューをどう実装するか

具体的な方法の紹介に入る前に、Next.jsで静的レンダリングされているサイトにプレビューを追加する場合、代表的な2パターンを改めて確認しておきましょう。

パターン1: Draft Modeを利用する場合(SSR環境がある場合)

Vercelにデプロイする場合など、SSR環境がある場合は、こちらのやり方がおすすめです。
Draft Mode を利用することで、プレビューを確認できます。
cookieで状態を制御し、Draft Modeが有効な場合のみ、動的にページを生成できます。
トークンの利用をサーバーサイドのみに限定できるので、コンテンツデータを保護できます。

この記事では、こちらのやり方について解説はしていません。興味のある方は、Next.jsのDraft Modeを利用して、プレビュー環境を作成する の記事をご確認ください。

パターン2: クライアントコンポーネントを利用する場合(SSR環境がない場合)

こちらはS3にデプロイする場合など、Static Exports を利用していて、SSR環境がない場合の方法となります。
プレビュー専用のページを用意し、クライアントコンポーネントを利用することで、動的にページを生成します。
トークンがフロントエンドに露出するため、セキュリティに問題がないか注意が必要です。

この記事では、こちらのやり方について解説します。

また上記の2つ以外にも、プレビュー用のステージング環境を用意するやり方など、様々な方法があります。サイトの特性や要件に応じて実装方法を選択してください。

1. Newt API Tokenを作成する

はじめに、Newtの管理画面に入り、スペース設定 > APIキー のページからNewt API Tokenを作成します。
※ 下書き中のコンテンツを取得するためには、Newt APIを利用します。

nextjs_preview2.jpg

名前と取得対象を決めて「作成」を押します。

nextjs_preview3.jpg

2. プレビューデータの取得メソッドを作成する

2-1. 環境変数の設定

Next.jsには環境変数のビルトインサポートがあり、.env.local を使用して、環境変数をロードできます。Next.jsの環境変数について、詳細は Environment Variables のドキュメントをご確認ください。

.env.local ファイルを作成しましょう。以下を実際の値で置き換えて定義してください。
NEXT_PUBLIC_NEWT_SPACE_UID には、プレビューデータの取得対象となるスペースUIDの値を設定します。
NEXT_PUBLIC_NEWT_CDN_API_TOKENNEXT_PUBLIC_NEWT_API_TOKEN にはNewtの管理画面で作成したTokenの値を設定します。
NEXT_PUBLIC_NEWT_PREVIEW_SECRET はプレビューリクエストが有効なものであるか検証するために利用します。ご自身で定めたシークレットを入力してください。
NEXT_PUBLIC_ をつけることで、フロントエンドからもアクセスできるようにしています。

.env.local
1NEXT_PUBLIC_NEWT_SPACE_UID=your-space-uid
2NEXT_PUBLIC_NEWT_CDN_API_TOKEN=xxxxxxxxxxxxxxx
3NEXT_PUBLIC_NEWT_API_TOKEN=xxxxxxxxxxxxxxx
4NEXT_PUBLIC_NEWT_PREVIEW_SECRET=hogehoge

上記のように定義しておくと、process.env.NEXT_PUBLIC_NEWT_CDN_API_TOKENprocess.env.NEXT_PUBLIC_NEWT_API_TOKEN として利用できるようになります。

2-2. プレビューデータの取得メソッドを作成する

Newt CDN API用のクライアントとNewt API用のクライアントをそれぞれ作成します。
token には2-1で設定した環境変数をそれぞれ入力します。

プレビューデータを取得する getArticleBySlug では、引数に isDraft を渡し、true の場合は apiClient を利用して下書きを含む全コンテンツを取得、false の場合は cdnClient を利用して公開コンテンツのみを取得するようにします。
newt-client-js を利用します。

※ appUid・modelUidには取得対象のApp UID・モデルUIDを設定してください。

lib/newt.ts
1import { createClient } from 'newt-client-js'
2import { cache } from 'react'
3import type { Article } from '@/types/article'
4
5// Newt CDN APIのクライアント(公開コンテンツのみ取得)
6const cdnClient = createClient({
7  spaceUid: process.env.NEXT_PUBLIC_NEWT_SPACE_UID + '',
8  token: process.env.NEXT_PUBLIC_NEWT_CDN_API_TOKEN + '',
9  apiType: 'cdn',
10})
11
12// Newt APIのクライアント(全コンテンツ取得)
13const apiClient = createClient({
14  spaceUid: process.env.NEXT_PUBLIC_NEWT_SPACE_UID + '',
15  token: process.env.NEXT_PUBLIC_NEWT_API_TOKEN + '',
16  apiType: 'api',
17})
18
19export const getArticles = cache(async () => {
20  const { items } = await cdnClient.getContents<Article>({
21    appUid: 'blog',
22    modelUid: 'article',
23    query: {
24      select: ['_id', 'title', 'slug', 'body'],
25    },
26  })
27  return items
28})
29
30export const getArticleBySlug = cache(
31  async (slug: string, isDraft: boolean) => {
32    const client = isDraft ? apiClient : cdnClient
33    const article = await client.getFirstContent<Article>({
34      appUid: 'blog',
35      modelUid: 'article',
36      query: {
37        slug,
38        select: ['_id', 'title', 'slug', 'body'],
39      },
40    })
41    return article
42  },
43)

3. 投稿詳細を表示するためのコンポーネントを作成する

プレビュー用のページとコンテンツ詳細ページの両方で投稿詳細を表示するために、共通で利用するコンポーネント ArticleDetail を作成しておきます。

components/ArticleDetail/index.tsx
1import styles from './styles.module.css'
2import type { Article } from '@/types/article'
3
4export function ArticleDetail({ article }: { article: Article }) {
5  return (
6    <main className={styles.main}>
7      <h1>{article.title}</h1>
8      <div dangerouslySetInnerHTML={{ __html: article.body }} />
9    </main>
10  )
11}

上記のコンポーネントをコンテンツ詳細ページから利用します。
コンテンツ詳細ページでは、プレビューデータを利用しないため(本番データを利用するため)、getArticleBySlug の第2引数は false とします。

app/articles/[slug]/page.tsx
1import { ArticleDetail } from '@/app/components/ArticleDetail'
2import { getArticles, getArticleBySlug } from '@/lib/newt'
3import type { Metadata } from 'next'
4
5type Props = {
6  params: {
7    slug: string
8  }
9}
10
11export async function generateStaticParams() {
12  const articles = await getArticles()
13  return articles.map((article) => ({
14    slug: article.slug,
15  }))
16}
17export const dynamicParams = false
18
19export async function generateMetadata({ params }: Props): Promise<Metadata> {
20  const { slug } = params
21  const article = await getArticleBySlug(slug, false)
22
23  return {
24    title: article?.title,
25    description: '投稿詳細ページです',
26  }
27}
28
29export default async function Page({ params }: Props) {
30  const { slug } = params
31  const article = await getArticleBySlug(slug, false)
32  if (!article) return
33
34  return <ArticleDetail article={article} />
35}

4. プレビューページを作成する

次に、プレビューページを作成します。プレビューページのパスは /articles/draft とします。
Client Components を利用して、フロントエンドからデータを取得できるようにします。
以下のように実装します。

app/articles/draft/page.tsx
1'use client'
2import { useSearchParams } from 'next/navigation'
3import { Suspense, useEffect, useState } from 'react'
4import { ArticleDetail } from '@/app/components/ArticleDetail'
5import { getArticleBySlug } from '@/lib/newt'
6import type { Article } from '@/types/article'
7
8function ArticlePage() {
9  const searchParams = useSearchParams()
10  const secret = searchParams.get('secret')
11  const slug = searchParams.get('slug')
12  const [article, setArticle] = useState<Article | null>(null)
13
14  useEffect(() => {
15    if (secret !== process.env.NEXT_PUBLIC_NEWT_PREVIEW_SECRET || !slug) return
16    ;(async () => {
17      const article = await getArticleBySlug(slug, true)
18      setArticle(article)
19    })()
20  }, [secret, slug])
21
22  return article ? <ArticleDetail article={article} /> : null
23}
24
25export default function Page() {
26  return (
27    <Suspense>
28      <ArticlePage />
29    </Suspense>
30  )
31}

以下のことを行っています。

  • useSearchParams を利用して、secretslug のクエリパラメータを読み取る
  • secretの値が正しいか、NEXT_PUBLIC_NEWT_PREVIEW_SECRET を利用して検証する
  • 該当slugの投稿について、プレビューデータを取得する
  • ArticleDetail にプレビューデータを渡す
  • useSearchParams を利用しているため、Suspense でラップしておく

このままでもプレビューを確認できますが、プレビューページが検索にかからないよう app/articles/draft/layout.tsx にnoindexを設定しておきましょう。
メタデータの設定の詳細については、Next.jsの Metadata をご確認ください。

app/articles/draft/layout.tsx
1import type { Metadata } from 'next'
2import type { ReactNode } from 'react'
3
4export const metadata: Metadata = {
5  robots: {
6    index: false,
7  },
8}
9
10export default async function Layout({ children }: { children: ReactNode }) {
11  return <>{children}</>
12}

これでプレビュー設定ができました。
変更をコミットして、デプロイしておきましょう。

5. プレビュー設定を行う

続いて、Newtの管理画面に入り、プレビュー設定を行います。
モデル設定の右上から「プレビュー設定」に進みます。

プレビュー用のAPIルート /articles/draftsecretslug のクエリパラメータをつけてアクセスするよう、プレビューURLを指定します。
サイトのドメインが https://nextjs-preview.newt.so、secretが hogehoge の場合、プレビューURLは以下のように指定します。

https://nextjs-preview.newt.so/articles/draft?secret=hogehoge&slug={slug}

モデルが slug というフィールドを持つ場合、{slug} のように記載することで、各コンテンツのslugの値がプレビューURLに展開されます。

nextjs-preview-static2.jpg

これで、Newtのプレビュー設定もできました。
コンテンツ編集画面からプレビューが見れるか確認しましょう。

もし、プレビューが見れない場合は、プレビューURLが正しく指定されているか、tokenやsecretの値が正しいか確認してみてください。

NewtMade in Newt