ブログ構築 99. Nuxt.jsブログカスタマイズ #02
2020年07月10日に公開

Contentfulの全文検索を使ったNuxt.jsブログ内検索の実装

この記事で作るもの

この記事ではContentfulの全文検索を使って、ブログ内検索を実装します。

完成イメージ

2020-07-10 17-22-26

具体的な実装手順

以下の流れで実装を行います。

  1. 共通ヘッダーの作成
  2. ヘッダー検索フォームの作成
  3. 検索表示ページの作成
  4. ヘッダー検索フォームからクエリーを投げる設定
  5. 検索フォームのバリデーション設定
  6. Contentfulの全文検索メソッド追加
  7. 検索メソッドの呼び出し
  8. 検索表示ページのフォームバリデーション設定
  9. 検索結果の表示

実装デモサイト

実際の動作は下記URLよりご確認ください。

https://demo-blog.cloud-acct.com/

最終的なコード

記事下部に掲載してあります。

前提条件

この記事を実装するにあたって以下の条件を満たしている必要があります。

  • Nuxt.js × Contentfulのブログプロジェクトが存在する
  • Contentfulに複数の記事が存在する
  • Nuxt.jsでContentfulの記事を取得できている

なお、以上の条件をまだ満たしていない方は「ブログ構築」カテゴリーを完走して、またここにいらしてください。

ブログ構築カテゴリーはNuxt.js v2.13未満での実装を推奨しています。

ダウングレードの方法はこの下記に記載しています。

Nuxt.js v2.13.0新機能メモの公開とv2.12.2にダウングレードする方法

Vuetifyをまだ導入されていない方

今回の検索フォームにはCSSフレームワーク「Vuetify」を使用します。

Vuetifyをまだ導入されていない方は導入をおすすめします。

Vuetifyの導入方法

モジュールをインストールします。

$ yarn add --dev @nuxtjs/vuetify

buildModulesへVuetifyを登録します。

nuxt.config.js
...
{
  buildModules: [
    '@nuxtjs/vuetify',
  ]
}

以上でVuetifyが使えるようになります。

Nuxt.js v2.13以上で実装される方

nuxt.config.jsに以下の記述がある場合、コンポーネントファイルのimport文は必要ありません。

nuxt.config.js
components: true

今回の記事内のimport文は無視してください。

それでは参りましょう。

1. 共通ヘッダーを作成する

まず、検索フォームを設置するヘッダーを作成しましょう。

「components」ディレクトリに「shared」ディレクトリを作成し、配下にmyHeader.vueファイルを作成しましょう。

$ mkdir components/shared && touch $_/myHeader.vue

myHeader.vueの編集

作成されたmyHeader.vueを編集します。

components/sherred/myHeader.vue
<template>
  <div>
    <v-app-bar
      dense
    >
      <nuxt-link
        to="/"
      >
        {{ siteName }}
      </nuxt-link>
    </v-app-bar>
  </div>
</template>

<script>
export default {
  data() {
    return {
      siteName: 'SiteName'
    }
  }
}
</script>
  • <v-app-bar> … Vuetifyのツールバーを使用しています。

    参考 App Bars - Vuetify

myHeader.vueの呼び出し

「layout」ディレクトリのdefault.vueからヘッダーを呼び出し、共通レイアウトとして扱います。

layouts/default.vue
<template>
  <v-app>
    <my-header />
    <nuxt />
  </v-app>
</template>

<script>
import myHeader from '~/components/shared/myHeader'

export default {
  components: {
    myHeader
  }
}
</script>

ヘッダーが表示されました。

2020-07-10 10-16-38

default.vueに設置することによって、どのページに遷移しても表示されますね。

サイト名にCSSを追加する

ただちょっとリンク表示がダサい。。。CSSを追加しましょう。

components/shared/myHeader.vue
...
	<!-- class 追加 -->
  <nuxt-link
    to="/"
    class="site-name" 
  >
    {{ siteName }}
  </nuxt-link>
...

<!-- styleタグの追加 -->
<style lang="scss">
a.site-name {
  text-decoration: none;
  color: rgba(0, 0, 0, 0.87);
  font-size: 1.25rem;
  line-height: 1.5;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>

このCSSを追加すると、サイト名が長くなってもいい感じに表示してくれます。

2020-07-10 10-52-39

これで検索フォームを置くヘッダーが作成できました。

2. ヘッダーに検索フォームを作成する

それではVuetifyの<v-text-field>を使って検索フォームを作成します。

「components/ui」ディレクトリ(無ければ作成)配下にsearchForm.vueを作成しましょう。

$ touch components/ui/searchForm.vue

検索フォームを作成する

searchForm.vueに検索フォームを追加します。

components/ui/searchForm.vue
<template>
  <div>
    <v-form>
      <v-text-field
        hide-details
        placeholder="キーワードを入力"
        dense
      />
    </v-form>
  </div>
</template>

<script>
export default {}
</script>
  • <v-form><v-text-field>をこのタグで囲むことでイベントを発火させることができます。(後述)

    参考 v-form component - Vuetify

  • hide-details … Vuetifyが用意しているプロパティで、フォーム下に表示されるエラーメッセージなどを非表示にします。

    エラーメッセージが必要ない場合や狭い場所にフォームを設置する場合に使用します。

検索フォームを呼び出す

それでは検索フォームコンポーネントをmyHeader.vueから呼び出しましょう。

components/shared/myHeader.vue
<template>
      ...
      <!-- 追加 -->
      <v-spacer />
      <search-form />
    </v-app-bar>
  </div>
</template>

<script>
// 追加
import searchForm from '../ui/searchForm'

export default {
  // 追加
  components: {
    searchForm
  },
  data() {
  ...
}
</script>

うまく行きましたね。

2020-07-10 11-25-28

3. 検索キーワードを受け取るsearch.vueを作成する

検索フォームへ入力されたキーワードを受け取るページを作成します。

ここで作成するページは3つの役目を持ちます。

  1. 検索キーワードを受け取る
  2. 受け取ったキーワードから記事検索を行う
  3. 検索結果を一覧で表示する

それでは「pages」ディレクトリ配下にsearch.vueを作成します。

$ touch pages/search.vue

検索キーワードを受け取る

search.vueには新たな検索フォームを設置し、そのフォーム内で検索キーワードを受け取ります。

  • ヘッダーの検索キーワード => search.vueのフォームに設置
pages/search.vue
<template>
  <v-container fluid>
    <v-form>
      <v-row
        align="center"
      >
        <v-col
          cols="12"
          sm="10"
          md="8"
        >
          <v-text-field
            v-model="query"
            outlined
            hide-details
            placeholder="キーワードを入力"
            autofocus
          />
        </v-col>
        <v-col
          cols="12"
          sm="2"
          md="4"
        >
          <v-btn
            color="primary"
          >
            検索する
          </v-btn>
        </v-col>
      </v-row>
    </v-form>
  </v-container>
</template>

<script>
export default {
  data() {
    return {
      query: ''
    }
  },
  watch: {
    '$route.query.q': {
      handler(newVal) {
        this.query = newVal
      },
      immediate: true
    }
  }
}
</script>

ごちゃごちゃとありますが、重要なのは<script>タグ内のコードです。

data

  • query … このページで使用する検索キーワードが入る変数です。

    この変数をContentfulに渡し、全文検索を実行します。

$route.query.q

Nuxt.jsのthis.$routeを使ってURLに埋め込まれたクエリーを取得しています。

クエリーとは下記のようなURLのq=の後の部分です。

http://localhost:3000/search?q=aaa

watch

値を監視し、変化があれば命令を実行するVueの機能です。

  • '$route.query.q' … URLに埋め込まれたクエリーをwatchのターゲットにしています。

    この値に変化があればhandler()内の命令が実行されます。

  • handler(newVal) … ターゲットに変化があればこのhandler内の命令が実行されます。

    newValには変化があった後の値が入り、第二引数には変化前の古い値が入ります。

    つまりここには、ヘッダーの検索フォームから渡された検索キーワードが入っています。

  • this.query = newValquery変数にキーワードを代入しています。

    これにより、このページ内で検索キーワードをthis.queryとして扱うことができます。

immediate: true

watchのオプションです。

通常watchは、ページが描画された後に監視を始め、その後変化があった場合に命令を実行します。

通常のwatch(ターゲットがqueryだった場合)
// この段階で監視対象とされる
data() {
  return {
    query: ''
  }
}

// 代入の段階で変化があったと認識される
this.query = 'aaa'

// watchの発火
watch: {
  query: {
    handler(newVal) {
      this.query = newVal
    },
    immediate: true
  }
}

immediate: trueオプションを付けると描画前に監視を始めます。

immediateオプションをつけた場合
// この段階でwatchが発火する
data() {
  return {
    query: ''
  }
},

// watchの発火
watch: {
  query: {
    handler(newVal) {
      this.query = newVal
    },
    immediate: true
  }
}

これによりターゲットの$route.query.qが宣言された直後の値も受け取ることができます。

4. ヘッダー検索フォームからクエリーを投げる設定を行う

上記でキーワードを受け取る設定はできたので、次はsearchForm.vueからクエリーを投げる設定を行いましょう。

components/ui/searchForm.vue
<template>
  <div>
    <!-- @submitの追加 -->
    <v-form
      @submit.prevent="submit"
    >
      <!-- ref・v-modelの追加 -->
      <v-text-field
        ref="searchForm"
        v-model="query"
        hide-details
        placeholder="キーワードを入力"
        dense
      />
    </v-form>
  </div>
</template>

<script>
export default {
  // 追加
  data() {
    return {
      query: ''
    }
  },
  methods: {
    submit() {
      this.$router.push({ path: '/search', query: { q: this.query } })
      this.query = ''
      this.$refs.searchForm.blur()
    }
  }
}
</script>

@submit.prevent=“submit”

フォーム上でエンターキーが押されたタイミングでイベントが発火します。

  • prevent … これはHtmlの仕様では、エンターキーのタイミングでフォームが送信され、ページがリロードされます。

    このプロパティを追加するとフォームを送信させないようにでき、ページのリロードを防ぐことができます。

  • submit … エンターキーが押されたタイミングで呼び出されるメソッドです。

this.$router.push

メソッド内でページ遷移を実行したい場合に使用します。

  • path: '/search' … 遷移先のパスを指定します。
  • query: { q: this.query } … クエリーを渡したい場合のオプションです。
  • this.queryにはユーザーが入力した検索キーワードが入ります。

this.$refs.searchForm.blur()

ページ遷移後に検索フォームのフォーカスを外しています。

  • this.$refs.<キー> … DOM(Htmlタグのこと)を直接操作したい場合に使用します。

    操作したいDOMにはref=<任意のキー>を設定する必要があります。

操作したいDOM(Html)
<v-text-field ref="searchForm">
操作する時(Javascript)
this.$refs.searchForm.<メソッド>
  • blur() … フォームのフォーカスを外すVue.jsの命令です。

実際に検索キーワードを投げてみよう

ヘッダーの検索フォームに文字を入力してエンターキーを押してみましょう。

2020-07-10 15-46-39

  1. URLにクエリーが紐づいていていますね。
  2. 入力したヘッダーの検索フォームは、フォーカスが外れ空になっています。
  3. そしてsearch.vueでは、検索フォームでクエリーを受け取ることができています。

この3つが確認できれば成功です。

5. 空白を許容しないバリデーションを設定する

ただ今のままでは空白の状態でも検索キーワードが投げられます。

試しにスペースを入力してエンターを押してみてください。

これでは無駄な動作なので、searchForm.vueにバリデーションを設定しましょう。

components/ui/searchForm.vue
...
<script>
export default {
  // 追加
  computed: {
    // 検索キーワードが有効な場合にtrueを返す
    validQuery() {
      return !!this.query && // 入力必須
        !/^\s+$/.test(this.query) && // 空白のみ禁止
        this.$route.query.q !== this.query // 値の変化
    }
  },
  methods: {
    submit() {
      // if文でメソッド内を囲む
      if (this.validQuery) {
        this.$router.push({ path: '/search', query: { q: this.query } })
        this.query = ''
        this.$refs.searchForm.blur()
      }
    }
  }
}
</script>
  • validQuery() … 以下の条件に当てはまるとエンターキーを押しても検索が実行されません。

    • 何も入力されていない => false
    • 空白だけの入力 => false
    • 前と同じ検索ワードの場合 => false

これでヘッダーの検索フォームが完成です。

6. Contentfulの全文検索メソッドを追加する

それではContentfulの全文検索機能を実装しましょう。

まずはsearch.vueに検索メソッドを追加します。

pages/search.vue
<template>
        ...
        <!-- 追加 -->
        <v-col
          cols="12"
        >
          検索結果{{ posts.length }}件
        </v-col>
      </v-row>
    </v-form>
  </v-container>
</template>

<script>
// 追加
import client from '~/plugins/contentful'

export default {
  data() {
    return {
      query: '',
      // 追加
      posts: []
    }
  },
  ...
  // 追加
  methods: {
    async getPosts() {
      await client.getEntries({
        content_type: process.env.CTF_BLOG_POST_TYPE_ID,
        query: this.query
      })
        .then(({ items }) => (this.posts = items))
        .catch(console.error)
    }
  }
}
</script>
  • getEntries() … Contentfulからコンテンツを取得するメソッドです。

    • content_type: <タイプ> … Contentfulのコンテンツタイプを指定します。
    • process.env.CTF_BLOG_POST_TYPE_IDには、「blogPost」という文字列が入っています。
    • query: '検索キーワード' … 全文検索を実行する際に使用します。
  • .then(({ items }) => (this.posts = items)) … Contentfulからはキーワードに一致する記事の配列が返されます。その配列を予め用意したposts配列に代入しています。

7. 検索メソッドを実行する

検索のタイミングは3つあります。

  1. クエリーが投げられた一番最初(ヘッダー検索フォーム)
  2. 検索フォーム上でエンターを押した時
  3. 検索するボタンをクリックした時

上記を踏まえてメソッドを呼び出しましょう。

pages/search.vue
		...
    <!-- 2. 検索フォーム上でエンターを押した時/@submit追加 -->
    <v-form
      @submit.prevent="getPosts"
    >
    ...
    <!-- 3. 検索するボタンをクリックした時/@click追加 -->
    <v-btn
      color="primary"
      @click="getPosts"
    >
      検索する
    </v-btn>
    ...
</template>

<script>
import client from '~/plugins/contentful'

export default {
  ...
  watch: {
    '$route.query.q': {
      handler(newVal) {
        this.query = newVal
        // 1. クエリーが投げられた一番最初
        this.getPosts()
      },
      immediate: true
    }
  },
 ...
</script>

全ての実装を確認してみてください。

  • ヘッダーの検索フォームから
  • 検索ページの検索フォームから
  • 検索するボタンから

全ての検索に結果が表示されていますね。

2020-07-10 16-48-59

8. 検索フォームにバリデーションを設定する

ただ今のままでは空白時にも検索が実装され、無駄なAPI通信が発生します。

this.queryが未入力の場合、空白のみの場合には検索をしないようバリデーションを設定しましょう。

pages/search.vue
...
<script>
import client from '~/plugins/contentful'

export default {
  data() {
    return {
      query: '',
      posts: []
    }
  },
  // 追加
  computed: {
    isRequired() {
      return !!this.query && !/^\s+$/.test(this.query)
    }
  },
  ...
  methods: {
    async getPosts() {
      // if文でメソッド内を囲む
      if (this.isRequired) {
        await client.getEntries({
          content_type: process.env.CTF_BLOG_POST_TYPE_ID,
          query: this.query
        })
          .then(({ items }) => (this.posts = items))
          .catch(console.error)
      }
    }
  }
}
</script>

これで未入力時と空白時に無駄な通信を行わないようになりました。

9. 検索結果を表示する

検索できた記事たちを表示しましょう。

Vuetifyの<v-list>を使ってpostsの配列をループします。

pages/search.vue
          ...
          <v-col
            cols="12"
          >
          <!-- 以下追加 -->
          <v-list-item>
            <v-list-item-content>
              <v-list-item-title>
                検索結果{{ posts.length }}件
              </v-list-item-title>
            </v-list-item-content>
          </v-list-item>

          <template v-if="loading">
            <div class="text-center">
              <v-progress-circular
                indeterminate
                color="grey"
              />
            </div>
          </template>
          <template v-else>
            <v-list class="py-0">
              <template v-if="posts.length">
                <v-list-item
                  v-for="(post, i) in posts"
                  :key="i"
                  :to="$store.getters.linkTo('posts', post)"
                >
                  <v-list-item-content>
                    <v-list-item-title>
                      {{ post.fields.title }}
                    </v-list-item-title>
                  </v-list-item-content>
                </v-list-item>
              </template>
              <template v-else>
                <v-list-item class="justify-center">
                  <div class="text-center">
                    <p>
                      キーワードに一致する投稿がありません。
                    </p>
                    <v-icon>
                      mdi-emoticon-cry-outline
                    </v-icon>
                  </div>
                </v-list-item>
              </template>
            </v-list>
          </template>
          <!-- ここまで -->
        </v-col>
      </v-row>
    </v-form>
  </v-container>
</template>

<script>
import client from '~/plugins/contentful'

export default {
  data() {
    return {
      query: '',
      posts: [],
      loading: false // 追加
    }
  },
  ...
  methods: {
    async getPosts() {
      if (this.isRequired) {
        this.loading = true // 追加
        await client.getEntries({
          content_type: process.env.CTF_BLOG_POST_TYPE_ID,
          query: this.query
        })
          .then(({ items }) => (this.posts = items))
          .catch(console.error)
        this.loading = false // 追加
      }
    }
  }
}
</script>
  • loading … 表示にタイムログがあるため、フラグを設置しています。

    エンドユーザーに対して検索中であることを明確にできます。

実際に検索してみましょう。

2020-07-10 17-22-26

投稿記事のリンクが表示されてますね。

検索結果がない場合はどうでしょう。

2020-07-10 17-22-26

うまく表示されました!

終わりに

以上で実装が完了しました。

ただこのままでは、全ての検索結果が表示されるのでページべネーションの設置も必要ですね。

ここまでできたあなたはもうその力があるはずです!

ぜひカスタマイズしてみてください🤠


この記事は、ココナラで出会った方の相談をきっかけに書き始めました。

人に頼るって本当に大切です。

頼られた方も嬉しいですから、遠慮せず気軽にslackやココナラからご相談くださいね。

最終的なコード

ヘッダー
components/shared/myHeader.vue
<template>
  <div>
    <v-app-bar
      dense
    >
      <nuxt-link
        to="/"
        class="site-name"
      >
        {{ siteName }}
      </nuxt-link>

      <v-spacer />
      <search-form />
    </v-app-bar>
  </div>
</template>

<script>
import searchForm from '../ui/searchForm'

export default {
  components: {
    searchForm
  },
  data() {
    return {
      siteName: 'SiteName'
    }
  }
}
</script>

<style lang="scss">
a.site-name {
  text-decoration: none;
  color: rgba(0, 0, 0, 0.87);
  font-size: 1.25rem;
  line-height: 1.5;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>
ヘッダー検索フォーム
components/ui/searchForm.vue
<template>
  <div>
    <v-form
      @submit.prevent="submit"
    >
      <v-text-field
        ref="searchForm"
        v-model="query"
        hide-details
        placeholder="キーワードを入力"
        dense
      />
    </v-form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      query: ''
    }
  },
  computed: {
    // 検索キーワードが有効な場合にtrueを返す
    validQuery() {
      return !!this.query && // 入力必須
        !/^\s+$/.test(this.query) && // 空白のみ禁止
        this.$route.query.q !== this.query // 値の変化
    }
  },
  methods: {
    submit() {
      if (this.validQuery) {
        this.$router.push({ path: '/search', query: { q: this.query } })
        this.query = ''
        this.$refs.searchForm.blur()
      }
    }
  }
}
</script>
検索結果表示ページ
pages/search.vue
<template>
  <v-container fluid>
    <v-form
      @submit.prevent="getPosts"
    >
      <v-row
        align="center"
      >
        <v-col
          cols="12"
          sm="10"
          md="8"
        >
          <v-text-field
            v-model="query"
            outlined
            hide-details
            placeholder="キーワードを入力"
            autofocus
          />
        </v-col>
        <v-col
          cols="12"
          sm="2"
          md="4"
        >
          <v-btn
            color="primary"
            @click="getPosts"
          >
            検索する
          </v-btn>
        </v-col>
        <v-col
          cols="12"
        >
          <v-list-item>
            <v-list-item-content>
              <v-list-item-title>
                検索結果{{ posts.length }}件
              </v-list-item-title>
            </v-list-item-content>
          </v-list-item>

          <template v-if="loading">
            <div class="text-center">
              <v-progress-circular
                indeterminate
                color="grey"
              />
            </div>
          </template>
          <template v-else>
            <v-list class="py-0">
              <template v-if="posts.length">
                <v-list-item
                  v-for="(post, i) in posts"
                  :key="i"
                  :to="$store.getters.linkTo('posts', post)"
                >
                  <v-list-item-content>
                    <v-list-item-title>
                      {{ post.fields.title }}
                    </v-list-item-title>
                  </v-list-item-content>
                </v-list-item>
              </template>
              <template v-else>
                <v-list-item class="justify-center">
                  <div class="text-center">
                    <p>
                      キーワードに一致する投稿がありません。
                    </p>
                    <v-icon>
                      mdi-emoticon-cry-outline
                    </v-icon>
                  </div>
                </v-list-item>
              </template>
            </v-list>
          </template>
        </v-col>
      </v-row>
    </v-form>
  </v-container>
</template>

<script>
import client from '~/plugins/contentful'

export default {
  data() {
    return {
      query: '',
      posts: [],
      loading: false
    }
  },
  computed: {
    isRequired() {
      return !!this.query && !/^\s+$/.test(this.query)
    }
  },
  watch: {
    '$route.query.q': {
      handler(newVal) {
        this.query = newVal
        this.getPosts()
      },
      immediate: true
    }
  },
  methods: {
    async getPosts() {
      if (this.isRequired) {
        this.loading = true
        await client.getEntries({
          content_type: process.env.CTF_BLOG_POST_TYPE_ID,
          query: this.query
        })
          .then(({ items }) => (this.posts = items))
          .catch(console.error)
        this.loading = false
      }
    }
  }
}
</script>
あなたの力になれること
私自身が独学でプログラミングを勉強してきたので、一人で学び続ける苦しみは痛いほど分かります。そこで、当時の私がこんなのあったら良いのにな、と思っていたサービスを立ち上げました。周りに質問できる人がいない、答えの調べ方が分からない、ここを聞きたいだけなのにスクールは高額すぎる...。そんな方に向けた単発・短期間メンターサービスを行っています。下のサービスへお進みください。
独学プログラマのサービス
次の記事はこちら
Udemy
SPA開発
0. 更新情報 #01
ブログ構築カテゴリーの記事修正、更新情報【2020/05/19追記: このカテゴリーの更新を一旦終了といたします】
0. 更新情報 #02
Nuxt.js v2.13.0新機能メモの公開とv2.12.2にダウングレードする方法
1. 今回作るアプリケーション #01
Nuxt.jsとContentfulで作るマイブログ
2. 開発環境にNuxt.jsを立ち上げる #01
Nuxt.jsを動かす環境を構築する
2. 開発環境にNuxt.jsを立ち上げる #02
Nuxt.jsのプロジェクトを作成する
2. 開発環境にNuxt.jsを立ち上げる #03
Hello Nuxtを表示する
3. Nuxt.jsアプリを公開する #01
Nuxt.jsをデプロイする前の事前準備を行う
3. Nuxt.jsアプリを公開する #02
Netlifyの初期セットアップとNuxt.jsのデプロイを行う
3. Nuxt.jsアプリを公開する #03
NetlifyにデプロイしたNuxt.jsに独自ドメインを設定する
4. Contentfulのセットアップ #01
【Nuxt.js Universal】Vuetify2.0にバージョンアップしよう
4. Contentfulのセットアップ #02
【画像で説明】Contentfulの使い方。初期設定と各メニューについて学ぶ
4. Contentfulのセットアップ #03
Contentfulにブログ記事モデルを作成していこう
4. Contentfulのセットアップ #04
ContentfulからAPIを取得してNuxt.jsで記事一覧を表示する
5. ブログ記事周りの構築 #01
Nuxt.jsにContentfulのブログ記事を表示する
5. ブログ記事周りの構築 #02
Contentfulから取得した下書き記事を開発環境に表示する
5. ブログ記事周りの構築 #03
Nuxt.jsのgenerateプロパティに動的なルーティングを追加する
5. ブログ記事周りの構築 #04
【Nuxt.js】middlewareを活用しブログ記事取得のパフォーマンスを改善する
6. カテゴリーページの構築 #01
【Contentful】カテゴリーモデルとブログ記事モデルの関連付け
6. カテゴリーページの構築 #02
【Nuxt.js × Contentful】ブログ記事に関連付くカテゴリーを表示する
6. カテゴリーページの構築 #03
【Nuxt.js × Contentful】カテゴリー記事一覧ページを作成する
7. タグ機能の構築 #01
Contentfulにタグモデルを作成し関連付けを行う
7. タグ機能の構築 #02
【Nuxt.js × Contentful】タグに関連付いたブログ記事を表示する
7. タグ機能の構築 #03
Contentfulのincludesを使って関連モデルを取得しタグ一覧ページを作成する
7. タグ機能の構築 #04
Vuetify2のdata-tableの使い方を学んで、タグ一覧ページをレイアウト
99. Nuxt.jsブログカスタマイズ #01
Twitterシェアボタン、フォローボタンの作り方【Nuxt.js Universalモード編】
99. Nuxt.jsブログカスタマイズ #02
Contentfulの全文検索を使ったNuxt.jsブログ内検索の実装
小ネタ集