10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Vue.js】Vue.js(Nuxt.js)とContentfullでブログ機能を実装

Last updated at Posted at 2021-03-15

##はじめに
弊社プロダクトでNuxt×Contentfullを使用したブログ機能を実装したので、ここに記しておきます〜:raised_hand:
弊社で2年ほど運営しているオウンドメディアがあったのですが、
CVRも低く、SEOも下がり気味だったので、マーケとの協議の結果、
**「本プロダクトに記事をがっちゃんこして、運営体制見直さね?」**となったわけです!
ブログ用のAPIと管理画面作るの面倒だし、Contentfullイケてそうだなあ〜と思って実装に入りました!!

##環境

  • MacOS
  • yarn 1.21.1
  • node 12.0.0
  • vue 2.6.1

Contentfullの設定

まずはContentfullの設定をしていきましょう!

###①Contentfullに新規登録・ログイン
Contentfullトップページからログインボタンを押してログイン画面へ。
スクリーンショット 2021-03-14 17.40.14.png
アカウントを持っていない人はサインアップしてください。
(持っている人はそのままログインでOK)
スクリーンショット 2021-03-14 17.40.54.png
必要情報を入力して新規アカウント作成!
英語なので少し読みづらいですね〜
スクリーンショット 2021-03-14 17.41.39.png

###②スペースの作成
新規登録・ログインができたらスペースを作成します。
スペースはプロジェクト単位で作成すると良いと思います!
初期設定では「The example project」というスペースが作成されているので、必要なければ削除してしまいましょう!
スクリーンショット 2021-03-14 17.44.24.png
スクリーンショット 2021-03-14 17.45.03.png
料金プランが選べるので、選択します。
プランによってDB容量やアカウントの管理権限設定が異なるようですが、まずはじめは無料で良いのかなと思います。
無料プランでは保存できる記事とアセット(画像など)の合計が5000個までなので、100記事くらいのメディアであれば無料で十分ですね。
ちなみに無料プランは各アカウントにつき1つしか作れないそうなので、無料プランを選択したい人は「The example project」を削除しておきましょう!

スクリーンショット 2021-03-14 18.10.54.png

スペース名を入力して作成します。
スクリーンショット 2021-03-14 18.17.41.png

確認画面が出たらOK!
スクリーンショット 2021-03-14 18.18.58.png

###③コンテンツモデルの作成
スペースができたらコンテンツの中身の設定をしていきましょう。
サイドバーから先ほど作ったスペースを選択します。
スクリーンショット 2021-03-14 18.20.31.png
ヘッダーに出てきた「Content Model」を選択し、コンテンツモデルを作成。
スクリーンショット 2021-03-14 18.21.22.png
スクリーンショット 2021-03-14 18.22.03.png

任意の名前と詳細を入力します。
今回は記事コンテンツを作るため、「Post」という名前にしました。
スクリーンショット 2021-03-14 18.23.08.png

###④フィールドの追加
コンテンツモデル内にフィールドを追加していきます。
記事の入力項目ですね!
スクリーンショット 2021-03-14 18.23.36.png

テキストや数字など様々選択できます。
「slug」を追加しました。urlになる文字列ですね。
スクリーンショット 2021-03-14 18.24.21.png
スクリーンショット 2021-03-14 18.29.07.png

今回は「slug」「title」「description」「thumbnail」「content」の5つを追加しました。

  • 「slug」…urlになる文字列
  • 「title」…記事のタイトル
  • 「description」…記事の概要
  • 「thumbnail」…サムネイル画像
  • 「content」…コンテンツの内容

スクリーンショット 2021-03-14 18.48.36.png

各フィールドごとに細かい設定も可能です。
「settings」から設定できます。

スクリーンショット 2021-03-14 18.51.40.png

今回は入力必須のバリデーションを設定します。
他にも文字制限やユニーク制限もありますね。
スクリーンショット 2021-03-14 19.01.11.png

これで一旦、Contentfull側の設定は完了です:ok_hand:

Nuxt.jsの設定

dotenvの追加

アプリケーションにdotenvの追加

ターミナル
npm install --save @nuxtjs/dotenv
もしくは
yarn add @nuxtjs/dotenv

ルート直下に.envを作成

ターミナル
touch .env

ContentfullでSPACE_IDとACCESS_TOKENを確認する。
スクリーンショット 2021-03-15 15.04.36.png
スクリーンショット 2021-03-15 15.05.49.png

確認したSPACE_IDとACCESS_TOKEとPOST_TYPE_IDを.env内に記載する。

.env

#CONTENTFUL
CTF_SPACE_ID=xxxxxx
CTF_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxx

nuxt.config.jsでdotenvを読み込み

nuxt.config.js
require("dotenv").config()

Contentfullプラグインの追加

Contentfullプラグインを追加します。

ターミナル
npm install contentful
もしくは
yarn add contentful

pluginsフォルダにcontentful.jsを作成。

plugins/contentful.js
const contentful = require('contentful')
module.exports = contentful.createClient({
  space: process.env.CTF_SPACE_ID,
  accessToken: process.env.CTF_ACCESS_TOKEN
})

nuxt.config.jsに諸々設定

nuxt.config.js
require("dotenv").config()
const client = require("./plugins/contentful")

// ~中略~

module.exports = {
  env: {
    // CONTENTFULの API_KEY を使う
    CTF_SPACE_ID: process.env.CTF_SPACE_ID,
    CTF_ACCESS_TOKEN: process.env.CTF_ACCESS_TOKEN
  },
}
plugins: [
    { src: '@/plugins/contentful' }
  ]

// ~中略~

  generate: {
    routes() {
      return client
        .getEntries({ content_type: 'column' })
        .then(entries => {
          return entries.items.map(entry => {
            return {
              route: "/posts/"+entry.fields.slug,
              payload: entry
            }
          })
        })
    }

プラグインや環境変数の読み込みをします。
またgenerateでslugをURLにする記載をしています。

###記事一覧ページ
pages配下にpostsフォルダーを作成し、その中にindex.vueを作成する。
これが記事一覧ページになります。

コードは参考までに、、、

posts/index.vue

<template>
  <section class="posts-lists-page">
    <h1 class="title-h1 title-bottom">コラム一覧</h1>
    <div class="posts">
      <nuxt-link
        v-for="(post, index) in posts"
        :to="'posts/'+post.fields.slug"
        :key="index"
        class="post">
        <div class="post-thumb">
          <img
            :src="post.fields.thumbnail.fields.file.url"
            class="post-thumb__img">
        </div>
        <div class="post-text">
          <p class="post-text__date">{{ formatDate(post.sys.createdAt) }}</p>
          <h2 class="post-text__title">{{ post.fields.title }}</h2>
        </div>
      </nuxt-link>
    </div>
  </section>
</template>

<script>
import content from '~/plugins/contentful'
export default {
  head() {
    return {
      title: this.title,
      meta: [
        { hid: 'description', name: 'description', content: this.description },
        {
          hid: 'og:url',
          name: 'og:url',
          content: `https://furien.jp${this.$route.path}`
        },
        { hid: 'og:title', name: 'og:title', content: this.title },
        {
          hid: 'og:description',
          name: 'og:description',
          content: this.description
        },
        { hid: 'twitter:title', name: 'twitter:title', content: this.title },
        {
          hid: 'twitter:description',
          name: 'twitter:description',
          content: this.description
        }
      ]
    }
  },
  data() {
    const title = 'コラム一覧'
    return {
      title: title,
      description: `${title}|役立つ記事を沢山配信しています。`
    }
  },
  methods: {
    formatDate(iso) {
      const date = new Date(iso)
      const yyyy = new String(date.getFullYear())
      const mm = new String(date.getMonth() + 1).padStart(2, '0')
      const dd = new String(date.getDate()).padStart(2, '0')
      return `${yyyy}.${mm}.${dd}`
    }
  },
  asyncData({ params }) {
    return content
      .getEntries({
        content_type: 'column',
        order: '-sys.createdAt'
      })
      .then(entries => {
        return { posts: entries.items }
      })
      .catch(e => console.log(e))
  }
}
</script>

<style lang="scss" scope>
.posts-lists-page {
  .posts {
    margin: 0 auto;
    padding: 10px;
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;

    .post {
      width: calc(100% / 2 - 20px);
      margin: 10px;
      background: #fff;
      text-decoration: none;
      color: #111;
      border: 1px solid rgba(162, 162, 162, 0.5);

      @include sp-small {
        width: 100%;
      }

      &-thumb {
        width: 100%;
        padding-bottom: 75%;
        position: relative;
        overflow: hidden;

        &__img {
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          max-width: 100%;
        }
      }

      &-text {
        padding: 1rem;

        &__date {
          font-size: 1.2rem;
          color: gray;
        }

        &__title {
          width: fit-content;
          font-size: 2rem;
          font-weight: bold;

          @include sp-small {
            font-size: 1.5rem;
          }
        }
      }
    }
  }
}
</style>

スタイルや表示する内容は適宜変更してくださいね〜
こんな感じになりますね!

スクリーンショット 2021-03-15 15.46.25.png

###記事詳細ページ
postsフォルダー内に_slugフォルダーを作成し、その中にindex.vueを作成する。
これが記事詳細ページになります。

posts/_slug/index.vue
<template>
  <article class="article-page">
    <div class="article">
      <BreadCrumbs :title="post.fields.title" />
      <h1 class="article__title">{{ post.fields.title }}</h1>
      <p class="article__date">{{ formatDate(post.sys.createdAt) }}</p>
      <div
        class="article__content"
        v-html="toHtmlString(post.fields.content)"/>
    </div>
  </article>
</template>

<script>
import { BLOCKS } from '@contentful/rich-text-types'
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'
import content from '~/plugins/contentful'

export default {
  head() {
    const ogImage = this.post.fields.thumbnail.fields.file.url
    return {
      title: this.post.fields.title,
      meta: [
        {
          hid: 'description',
          name: 'description',
          content: this.post.fields.description
        },
        {
          hid: 'og:url',
          name: 'og:url',
          content: `https://furien.jp${this.$route.path}`
        },
        { hid: 'og:title', name: 'og:title', content: this.post.fields.title },
        {
          hid: 'og:description',
          name: 'og:description',
          content: this.post.fields.description
        },
        {
          hid: 'og:image:secure_url',
          name: 'og:image:secure_url',
          content: ogImage
        },
        {
          hid: 'twitter:title',
          name: 'twitter:title',
          content: this.post.fields.title
        },
        {
          hid: 'twitter:description',
          name: 'twitter:description',
          content: this.post.fields.description
        },
        { hid: 'twitter:image', name: 'twitter:image', content: ogImage }
      ]
    }
  },
  methods: {
    formatDate(iso) {
      const date = new Date(iso)
      const yyyy = new String(date.getFullYear())
      const mm = new String(date.getMonth() + 1).padStart(2, '0')
      const dd = new String(date.getDate()).padStart(2, '0')
      return `${yyyy}.${mm}.${dd}`
    },
    toHtmlString(obj) {
      const options = {
        renderNode: {
          [BLOCKS.EMBEDDED_ASSET]: ({
            data: {
              target: { fields }
            }
          }) => `<img src="${fields.file.url}"/>`
        }
      }
      return documentToHtmlString(obj, options)
    }
  },
  asyncData({ params, error, payload }) {
    if (payload) return { post: payload }
    return content
      .getEntries({
        content_type: 'column',
        'fields.slug': params.slug
      })
      .then(entries => {
        return { post: entries.items[0] }
      })
      .catch(e => console.log(e))
  }
}
</script>

<style lang="scss" scope>
.article-page {
  .article {
    margin: 0 auto;
    padding: 1rem;
    color: #222;

    &__title {
      margin: 5% 0;
      font-size: 3rem;
      font-weight: bold;

      @include sp-small {
        font-size: 2rem;
      }
    }

    &__date {
      font-size: 1.4rem;
      color: $dark-gray;
    }

    &__content {
      //〜中略〜
    }
  }
}
</style>

ここでのポイントはコンテンツをリッチテキストにしている点と画像の処理方法ですね!
コンテンツをリッチテキストにしたのは移行の際に、マーケがコピペで記事を移行できるようにするためです:point_up:
いろんなやり方があると思いますので、試してみてください!

こんな感じになります!
スクリーンショット 2021-03-15 16.01.11.png

サーバー(heroku)の環境変数

あとは本番環境にも.envに設定した環境変数を設置してあげればOKです!!

##完成!!:clap:
これで完成です!!!
細かい設定、表示内容やスタイルなどは各々カスタマイズしていただければOKです!
Contentfull公式も結構丁寧に解説してくれてるので、一読してみてくださいね〜

##おわりに
最近わりと大きなプロジェクトのPMを任されていて、なかなかコードをかけない日々を送っています:joy:
時間を見つけてコード書けるように精進していきます〜:joy:

##参考
ContentfulとNuxtの連携の仕方①
https://izm51.com/posts/nuxt-contentful-netlify-blog-making-1/
ContentfulとNuxtの連携の仕方②
https://izm51.com/posts/nuxt-contentful-netlify-blog-making-2/
ContentfulのRichtextで画像を表示する方法
https://github.com/contentful/rich-text/issues/61
画像表示
https://tech.hajimari.inc/entry/2019/06/05/171553

10
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?