記事一覧へ
システム構築
エンジニアリング
作成日 : 2022-03-24
更新日 : 2022-04-07

ブログをサブドメインに切り出し、Nuxt から Next へ移行

目的

技術的負債

1.Nuxt(Vue)の問題

旧サイトは Nuxt(Vue)アプリを採用して 2020 年中旬に作成した。 Node、javascript Framework、仮想 DOM 等の概念をあまり理解していないがモダンなフロントエンド開発を学習しなければいけないと思っている状態だった。


仮想 DOM を採用してフロントエンドでリッチな組み換えを行うのが主流になりつつあること、そのフレームワークは React と Vue がおすすめだという情報を入手。WEB サイトのフロントエンド構築は HTML + CSS + javascript しか採用したことがない状態では Vue の Template 構文は非常に魅力的に見えたので採用した。


ある程度仮想 DOM の概念に慣れた後に React 及び Next も学習した所、最初は理解が難しかったが JSX の自由度、Typescript との親和性が圧倒的に React のほうが高く、新規開発はすべて Vue は使わずに React を採用していた。特に Typescript は敬遠していたが、型を先につけることで自然と test diven に近い開発ができたことは、とても驚くとともに良い開発体験となった。


Vue は Vue3 + Composition API により Typescript フレンドリーになりつつ合ったが、新旧の仕組みが入り混じった状態ではライブラリの相性問題等に時間を取られることが多かった。インターネット上の情報も新旧入り混じっていたので、ストレスが非常に高かった。Vue にはもう触りたくなかったが、せっかく構築したサイトを継続開発せずに放置するのももったいないと感じたため、React(Next)への移行を決めた。

2.設計の問題

学習のため Wordpress や Hatenablog を使用せずブログシステムを自分で作成したが、URL 設計やコンポーネント設計をきちんとせずにゴリ押しで開発をすすめた。ブログもサイトのサブディレクトリに設置したため、構成が複雑になり修正がしにくい状態となっていた。


将来的に CMS やブログサービスに移行する可能性も考えて、サブディレクトリ式ではなくサブドメインで切り分け、移行しやすい状態に変更した。サブドメイン式にすれば機能ごとに移行がしやすいというのも大きな利点である(複数のマイクロサービスを組み合わせるイメージ)。

構成

旧構成

DNS 等

ドメイン:ムームードメインで取得

DNS:Cloudflare を参照

Cloudflare:SSL 化 と Heroku への DNS プロキシ

メインサイト

Hosting : Heroku

Framework : Nuxt(SSR)


https://k4a.me
    L document
    |   L index
    |   L topic
    |   |  L ?id=XX
    |   L poem
    |      L ?id=XX
    L other...

新構成

DNS 等

ドメイン:ムームードメインで取得

DNS:Cloudflare を参照

Cloudflare:SSL 化 と Heroku、Netlify への DNS プロキシ

メインサイト

Hosting : Heroku

Framework : Nuxt(SSR)


https://k4a.me
    L other...

ブログサイト

Hosting : Netlify

Framework : Next(Static Export)


https://log.k4a.me
    L [slug]

作業

Hosting 先の選定

前提

サイトにはアドセンスやアフィリエイトを一応配置はしてあるが、本格的に広告収入を見込む予定は無い。そのため維持費は可能な限り抑えたい。

旧サイトはすべて Heroku の 1dyno で起動している。

Heroku は非常に使いやすいが、無料ユーザでは 1000h の dyno 起動制限があり、常に立ち上げておくのは 1dyno が限界である。

候補

  1. Heroku 有料プラン($7/月)
    • メリット
      • 1000h 制限がなくなる
      • SSL のために Cloudflare をプロキシする必要がなくなる
    • デメリット
      • 課金はユーザごとではなくプロジェクト毎のため、2つプロジェクトを動かすには 2 × $7 = $14/月 がかかる
  2. firebase hosting(無料プラン)
    • メリット
      • Heroku ほどではないが手軽
      • コストは掛からない
    • デメリット
      • 月の転送制限が低い(10G)
  3. Netlify(無料プラン)
    • メリット
      • 手軽
      • 無料のホスティングサービスの中では制限が少ない
    • デメリット
      • 静的 Hosting のため、SSR やサーバサイドの処理はできない
  4. その他 VPS
    • メリット
      • 性能制限に達しなければプロジェクト数は自由
    • デメリット
      • 運用・保守の手間がかかる

結論

ブログサイトではサーバサイドの処理をする予定はないため静的ファイルの Hosting で十分なこと、触ってみた所 Heroku よりも簡単なくらいにすぐにデプロイできたことから Netlify を選択した。

DNS やデプロイの設定

DNS は Cloudflare の DNS の CNAME にサブドメインを追加するだけですぐに Netlify に接続された。

デプロイは Heroku と同様に Github のリポジトリをセットし push されたら Continuous Deployment が行われる構成にした。


build settings は各自設定が異なると思うが、nuxt と next のビルドコマンドは違うので注意する。

また、package.json には export(静的出力)のスクリプトがデフォルトで記載されていないため追加する。


  • package.json

{
  ...
  "scripts": {
    "dev": "next dev",
    "postbuild": "node ./src/scripts/sitemap.xml.mjs",
    "build": "next build && next export && npm run postbuild",
    "start": "next start",
  },
  ...
}

  • build settings

Build command: npm run build
Publish directory: out

URL

URL 設計

旧サイトではk4a.me/document/topic?id=XXXという URL を採用していたが、新サイトではシンプルにlog.k4a.me/XXXで記事にアクセスできるようにする。

Next.js のDynsmic Routesを使用し、pages 配下に存在しないルートは記事 ID としてアクセスさせる。


L pages
    - [id].tsx -> 記事
    - _app.tsx
    - index.tsx
    ...other

リダイレクト

旧サイトの document 配下にアクセスした場合は、新サイトにリダイレクトされるように nuxt プロジェクトを更新する。

$route.push()を使用したリダイレクトはサイト内に変換されてしまうため、window.location.replaceを使用する。


  mounted() {
    const id = this.$route.query.id
    window.location.replace(`https://log.k4a.me/${id}`)

    //   this.$router.push({
    //     path: `https://log.k4a.me/${id}`, → https://k4a.me/documents/https:/log.k4a.me/XXXXとなってしまった
    //   })
  }

記事の管理

マークダウンファイル

記事はマークダウンで記述する。

public/assets/articles配下に作成したディレクトリを記事 ID として、その中に配置したindex.mdを記事内容として扱う。


public
    L /assets
        L /articles
            L /XXX
                L index.md
                L /image

記事の取得

Next.js で Markdown ブログを作るを参考に記事一覧、内容を取得する module を作成する。


import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { Post } from 'types'

const postsDirectory = path.join(process.cwd(), 'public/assets/articles')

/**
 * postsDirectory 以下のディレクトリ名を取得する
 */
export function getPostids() {
  const allDirents = fs.readdirSync(postsDirectory, { withFileTypes: true })
  return allDirents.filter((dirent) => dirent.isDirectory()).map(({ name }) => name)
}

/**
 * 指定したフィールド名から、記事のフィールドの値を取得する
 */
export function getPostById(id: string, fields: string[] = []) {
  const fullPath = path.join(postsDirectory, id, 'index.md')
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)

  const items: Post = {
    id: '',
    content: '',
    title: '',
    createdate: '',
    updatedate: '',
    sumbnail: '',
    tags: [],
  }

  fields.forEach((field) => {
    if (field === 'id') {
      items[field] = id ? id : items[field]
    }
    if (field === 'content') {
      items[field] = content ? content : items[field]
    }
    if (field === 'title' || field === 'createdate' || field === 'tags' || field === 'sumbnail') {
      items[field] = data[field] ? data[field] : items[field]
    }
  })

  return items
}

/**
 * すべての記事について、指定したフィールドの値を取得して返す
 * @param fields 取得するフィールド
 */
export function getAllPosts(fields: Array<keyof Post>) {
  const ids = getPostids()
  const posts = ids
    .map((id) => getPostById(id, fields))
    .sort((a, b) => (a.createdate > b.createdate ? -1 : 1))
  return posts
}

pages/index.tsxからはgetAllPostsで記事一覧を、pages/[id].tsxからはgetPostByIdで記事内容を取得する。

マークダウンの変換

既存のマークダウンパーサはたくさん存在するが、ここでは nuxt 時代に自作したパーサを使用する。

このパーサは markdown→html に変換するのではなく、マークダウンを解析し階層化したオブジェクトに格納する。


React 等の仮想 DOM では HTML に変換するよりオブジェクトにしたほうがコンポーネントが組み立てやすい。また、nuxt 時代のブログではアウトライナーのように階層を折り畳めるようなしくみにしたかったのでこのパーサを作成した。


  • markdown

# タイトル1
## タイトル2
こんにちは

  • object

{
  "tag": "h1",
  "content": "タイトル1",
  "child": {
    "tag": "h2",
    "content": "タイトル2",
    "child": {
      "tag": "none",
      "content": "こんにちは"
    }
  }
}

サイトマップの追加

next-sitemap

最初にnext-sitemapを試してみた。

公式を参考プロジェクトルートにnext-sitemap.config.jsを追加してスクリプトに追加し実行する。


module.exports = {
  siteUrl: process.env.SITE_URL,
  generateRobotsTxt: true,
  sourceDir: out,
}

{
  "postbuild": "next-sitemap --config ./next-sitemap.config.js"
  "build": "next build && next export && npm run postbuild",
}

開発環境でも Netlify でも正常に動作したが、Google Search Console 上で問題が発生した。

next-sitemap は sitemap.xml をインデックスサイトマップにして、sitemap-1.xml、sitemap-2.xml...に実際の sitemap を記載する形で出力される。


sitemap.xml を GSC に登録すると、検出 URL が 0 になり、sitemap-1.xml を登録するとエラーとなった。他ツール等で確認し XML 記法に間違いがないことは確認した。


この情報が正しいかは定かではないが、Search Console で検出された URL が 0 になるバグを回避する方法。によると「ハイフン」が含まれている、sitemap の url が長い、などの条件で正しい sitemap でも GSC 上ではエラーとなる場合があることがわかった。

next-sitemap でこの命名規則を変更できないかを調べたが、ハイフン前は変更できるがハイフン自体は変更する方法が見つからなかった。


他の有用なパッケージが見つからなかったため、パッケージは使わないことにした。

getServerSideProps

まず、Next.js で動的に XML サイトマップを生成するを参考に、page/sitemap.xml.tsxを作成し、xxx/sitemap にアクセスした時に、動的にgetServerSidePropsからxmlを返却する方式を採用した。


next devnext startでサーバ動作をさせているときは正常に動いたが、next exportで静的出力するとエラーが出力された。

静的出力ではgetServerSidePropsは使用できないためgetStaticPropsに変更したが、getStaticPropsではGetServerSidePropsContextを使用した responce は返却できない(サーバが存在しないため)。

そのため、この方法も断念した。

postbuild

次に、Create a Dynamic Sitemap with Next.jsを参考に、ビルド時にカスタムスクリプトを起動し、outsitemap.xmlを吐き出す方式を採用した。


export function getAllPosts(fields) {
  ...
  return posts
}

async function generateSitemapXml() {
  let xml = `<?xml version="1.0" encoding="UTF-8"?>`
  xml += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`

  const appHost = 'https://log.k4a.me/'

  // ここでurlを足していく
  const allPosts = await getAllPosts(['id', 'createdate', 'updatedate'])
  allPosts.forEach((post) => {
    xml += `
        <url>
          <loc>${appHost}${post.id}</loc>
          <lastmod>${post.updatedate ? post.updatedate : post.createdate}</lastmod>
          <changefreq>weekly</changefreq>
        </url>
      `
  })

  xml += `</urlset>`
  return xml
}

async function generate() {
  const xml = await generateSitemapXml()
  // eslint-disable-next-line no-sync
  writeFileSync('out/sitemap.xml', xml)
}

generate()

  "scripts": {
    "postbuild": "node ./src/scripts/sitemap.xml.mjs",
    "build": "next build && next export && npm run postbuild",
  },

ローカルの動作、Netlify 上での動作ともに正常で、GSC でも sitemap が認識されたためこの方式で決定する。

Google Adsence の追加

その他

Netlify 上のプラグインが失敗

ローカルでは問題ないのに、Netlify 上でPlugin "@netlify/plugin-nextjs" failedとなる。

Netlify サイトのPluginsタブにあるplugin-nextjsを削除した所正常に動作した。

次の記事

【OPPO】画面を傾けたときに表示される回転ボタンを無効にする

前の記事

Turing(USのエンジニアエージェント)からのスパムメールにクレームを入れる

記事一覧へ