Nuxt 3 を Google App Engine 本番環境 で SSR + キャッシュ運用してみる

※こちらの記事は↓にお引越ししました。念のためどちらも残しています。

https://zenn.dev/ytr0903/articles/81c8bed9a60702

# はじめに

　去る10月12日、Nuxt.jsのバージョン3のPublic Beta版が公開されました。

https://v3.nuxtjs.org/

　さっそくダウンロードして一通り触ってみていますが、個人的にはかなり好感触です。

　これまでVue 3で開発スタートする際の有力な選択肢だったViteと比較しても、SSR、ルーティング（vue-router）、Metaタグ（vue-meta）、ストア、TypeScript、JSX/TSX記法などを追加設定なしで使えるのが非常に大きく、

　「必須ではないがほとんど必須に近い、または自分で設定するのが地味に面倒な機能」が最初からセットアップされた状態から開発をスタートできるため、Vue 3で開発するハードルが格段に下がりました。

　ベータ版なので当然ながら不安定な部分や未提供の機能などがあるものの、現状でも既に利用する価値のあるフレームワークになっています。



　そんなNuxt 3、当然ながらまだプロダクション環境での利用は推奨されていませんが、せっかく提供開始されたからには何か作ってみたいし、もし作れたらそれをデプロイまでしたいはずです。

　ということでこの記事では、**あえて今すぐプロダクション環境で実際に動かしてみる方法**を説明したいと思います。

**※あくまで11月3日時点で動いた内容であり、タイミングによっては動かなくなる可能性も十分にあります。最新情報は公式ドキュメントを参照してください。**

**※ここでの「本番環境」とは「ローカルではない広く公開できる環境」を指します。「本番アプリケーション」を動かすことは自己責任でお願いします。**


## どんなアプリケーションを作るのか

　やってみるとは言っても、例えばSPAサイトを静的ビルドとしてホスティングするだけであれば[Netlifyなどにそのままデプロイするだけ](https://v3.nuxtjs.org/docs/deployment/netlify)で特に困るポイントはないので、もう少し複雑な要件を設定します。

- 動的ルーティングができる
- OGP対応のためSSRまたはSSGが必須（SPAは不可）
- 初回アクセス以外ではキャッシュにアクセスさせる

　要するに**Next.js + Vercel + ISRみたいなことをします**。

　ところで、NuxtでOGP対応と言えばまず真っ先に浮かぶのは静的サイト生成（SSG）だと思いますが、**Nuxt 3は現在 `nuxt generate` に対応していません**。

https://v3.nuxtjs.org/getting-started/introduction

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/2b3bd02b-8a8f-b90d-0c8f-0d3ea34d5fbb.png" width="600"/>

　そのため、現状でOGP対応を行うのであれば選択肢はSSRに絞られます。VercelとかLambdaとかいろいろ選択肢はあると思いますが、今回は[Google App Engine](https://cloud.google.com/appengine/)（以下GAE）を選ぶことにしました。

　理由としては、Cloud Functionsを使うような「サーバーレス環境でのSSR」は設定にコツや制限があることが多く、不安定かつドキュメント不足なNuxt3 Betaで実現するのが、このあたりに詳しくない自分にはハードルが高かったからです（というか試している最中で諦めました）。

　その点GAEは最初からサーバーサイドで動かすことを前提にしたアプリケーションなので、少ない設定でSSRを素直に設定しやすいと考えたためです。個人的にも以前にも一度（Nuxt 2で）試したことがあり、難易度は高くない印象を持っています。無料枠もあります。

https://zenn.dev/catnose99/articles/6442602e9c47d5

　また、Cloud CDNと組み合わせることで、`stale-while-revalidate` やキャッシュの手動破棄ができ、Next + ISRに近い環境を容易に作れそうなのも魅力的でした。

　

# Nuxt 3でSSRアプリケーションを作る

**※この章はNuxt 3の主要機能の一部を駆け足で触るだけの内容なので、興味のない方・GAEへのデプロイ方法だけ知りたい方は飛ばしてください。**

まずは[公式ドキュメント](https://v3.nuxtjs.org/getting-started/installation)に沿ってNuxt 3の新規アプリケーションを作成します。

```
npx nuxi init nuxt3-app
code -r nuxt3-app
yarn install
yarn dev
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/11d40113-d18c-1824-f76a-f5891b125c77.png" width="50%">

Nuxt 2の時のようなフレームワーク選択などもなく、一瞬です。起動も爆速。

## script setup

デフォルトで用意されている `App.vue` を削除して、`pages` ディレクトリを作成。このあたりはNuxt 2と同じですね。

せっかくなので[script setup syntax](https://v3.vuejs.org/api/sfc-script-setup.html)を使ってみます。

```vue:pages/index.vue
<script lang="ts" setup>
const count = ref(0);

const doubled = computed(() => count.value * 2);
</script>

<template>
  <div>
    <button @click="count++">Click Me!</button>
    <p>
      Single: {{ count }}<br />
      Double: {{ doubled }}
    </p>
  </div>
</template>
```

驚くほどシンプルに書けるようになりました。ref や computed などが自動でインポート・型補完されるのも凄いです。

## TSX、vue-router、SCSS

Vue 3ではJSX/TSXが正式にサポートされたので、こちらの書き方も試してみます。

ちなみにVue × TSX で書けると何が嬉しいかについては、Nuxt 2の頃に書いたことがあるので、気になる方はそちらをご覧ください（PR）。

https://tech.andpad.co.jp/entry/2021/07/01/170000

驚くべきことに、composition-api や vite と違って追加のプラグインすら不要で、`tsconfig.json` の `compilerOptions` に `jsx: preserve` を指定するだけでTSXが使えます。

また、SCSSについても設定は必要なく、 `yarn add sass` だけで使えるようになります。

```vue:pages/[id].vue
<script lang="tsx">
export default defineComponent({
  setup() {
    const { params } = useRoute();
    return () => (
      <div class="container">
        <div>{params.id}</div>
      </div>
    );
  },
});
</script>

<style lang="scss">
.container {
  width: 100%;
}
</style>
```

こちらも特に困ることはなく、簡単にパスを表示させることができました。

ちなみに動的ルーティング。ファイル名が `_id.vue` から `[id].vue` に変わりました。Next.jsと揃いましたね。

ただし、TSXで書いていると（特にSSR時に）エラーが出ることがそこそこ多かったので、安定版がリリースされるまでの間は、Vue Templateで書いた方が良いかもしれません。

## SSR対応

デフォルトではSSRではなくSPAになっているので、nuxt.config.tsでSSRオプションをtrueにします。

```ts:nuxt.config.ts
import { defineNuxtConfig } from "nuxt3";

export default defineNuxtConfig({
  rootDir: "./project",
  ssr: true,
});

```

サーバーサイドでのデータ取得については[Nuxt 3 - Data Fetching](https://v3.nuxtjs.org/docs/usage/data-fetching) のページを参照して実装できます。

上記の通りTSXだといろいろエラーが出たので一旦script setupで書き直します。

```vue:[id].vue
<script lang="ts" setup>
import dayjs from "dayjs";
const { params } = useRoute();
const clientDate = ref();
onMounted(() => {
  clientDate.value = dayjs().toString();
});

const { data: serverDate } = await useAsyncData("date", async () => {
  const date = dayjs().toString();
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return date;
});
</script>

<template>
  <div class="box">
    <div>Path: {{ params.id }}</div>
    <div>Server Date: {{ serverDate }}</div>
    <div>Client Date: {{ clientDate }}</div>
  </div>
</template>
```

server dateがサーバーサイドで取得された時刻、client dateがクライアントサイドで取得された時刻。asyncDataで1秒待たせます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/96948329-d47c-92ef-9a51-fb5158fa0956.png)

このように約1秒のズレが発生していることから、サーバーとクライアントで別々に時間を取得していることが確認できました。

## OGP設定

OGP表示が確認できるように設定します。この useMeta も自動インポートです。すごい。

```vue:[id].vue
<script lang="ts" setup>
/** 上の項と同じ内容なので省略 **/

useMeta({
  title: `${params.id} - Nuxt 3 x GAE Demo Page`,
  meta: [
    { property: "og:title", content: `${params.id} - Nuxt 3 x GAE Demo Page` },
    { property: "og:description", content: `IDが${params.id}のページです` },
    {
      property: "twitter:title",
      content: `${params.id} - Nuxt 3 x GAE Demo Page`,
    },
    {
      property: "twitter:description",
      content: `IDが${params.id}のページです`,
    },
  ],
});
</script>
```

## その他

[CSSフレームワークのBulma](https://bulma.io/)をグローバルに読み込んでboxの見た目を整えたりしていますが、今回の主旨とは関係ないので割愛します。

## プロダクションビルド

一旦SSRの動作確認ができたので、ビルドを行います。といっても `yarn build` するだけです。

その後、`yarn start` でビルドされたアプリケーションが実際に動作することを確認できます。

ちなみにプロジェクトディレクトリを `rootDir: ".project"` のように作業用ディレクトリを移動させている場合は、package.jsonのコマンドも `"start": "node project/.output/server/index.mjs"` に書き換える必要があります。

　

# Google App Engine へのデプロイ

SSRのビルドまで完了したので、実際にデプロイしてみます。

## プロジェクトの準備

GCPのアカウントの作成や、課金をオンにするなどの手順については割愛します。

[Google App Engine](https://console.cloud.google.com/appengine) にアクセスし、プロジェクトがなければ作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/d771c653-9819-ab3f-56ba-fcf991af2d93.png)

プロジェクト名を今回は **nuxt3-gae-demo** とし、アプリケーションの作成に進みます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/2ad25def-7025-55e4-677b-3b1cacabf8cf.png)

リージョンは東京である`asia-northeast1`を選びます（usの方が多少安いと思いますが、今回は速度と価格のバランスを試したいため）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/9ff8aaa8-6312-108b-8da3-85f857f53928.png)

リソースの言語はNode.js、Environmentは標準を選択してください（フレキシブルは無料で運用できません）。

## Google Cloud SDKの導入

Cloud SDKをローカルに導入します。なお、ここではCIツールなどとの連携は説明しません。

「Cloud SDKをダウンロード」をクリックすると、[Google Cloud SDK導入手順を確認できるページ](https://cloud.google.com/sdk/)に飛びますので、OSごとの手順に従ってください。私の場合はWSL2なのでLinuxの手順に従います。

```bash
$ cd ~
$ curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-240.0.0-linux-x86_64.tar.gz
$ tar zxvf google-cloud-sdk-240.0.0-linux-x86_64.tar.gz -C ~
$ ./google-cloud-sdk/install.sh
$ gcloud init
```

`gcloud init` を行うと、Googleアカウントでのログイン、その後プロジェクトの選択が求められます。このあたりは特に迷うことはないと思います。

## app.yaml と .gcloudignore の作成

GAEにデプロイするためには `app.yaml` が必要です。

ちなみにここの設定を間違えると想定以上の課金請求が来る可能性があるので、なるべくこの記述を鵜呑みにせずに自分でも調べてください。

```app.yaml
runtime: nodejs14

instance_class: F1

automatic_scaling:
  min_instances: 0

handlers:
  - url: /.*
    script: auto
    secure: always

env_variables:
  NUXT_HOST: "0.0.0.0"
  NUXT_PORT: "8080"
  NODE_ENV: "production"
```

instance_classは最小、min_instances: 0でデフォルトでインスタンスが起動し続けないようにします。

hostとportをセットしているので、package.jsonのスクリプトも `"start": "HOST=0.0.0.0 PORT=8080 node ./project/.output/server/index.mjs"` に書き換えます。

さらに、`gcloud app deploy` はデフォルトで現在のディレクトリのファイルをすべてアップロードしてしまうので、 `.gcloudignore` で必要なファイル以外を除外します。

[.gcloudignore で全部無視して必要なものだけ指定する - ぽ靴な缶](https://blog.pokutuna.com/entry/allowlist-gcloudignore) などの記事を参考にしながら、基本的には `project/.output` ディレクトリ以下のみがアップロードされるように設定します。

```shell:.gcloudignore
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
#   $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore

# Node.js dependencies:
node_modules/

.nuxt/
project/*
!project/.output*
```

### デプロイしてみる

`gcloud app deploy` のコマンドを叩いてファイルをアップロードします。

完了したら `gcloud app browse` でサイトを開きます。これで先程 `yarn start` したのと同じ画面が…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/e1c50527-2267-7068-deff-a5c85d5ac8e4.png)

**出ませんでした**。

GAEのダッシュボードからエラーレポートを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/23f3f936-4263-bda9-f83c-acec4ae3c485.png)

unenvが足りないと言われています。https://console.cloud.google.com/debug でアプリケーションのデバッガを開くとデプロイされているファイルの一覧を見ることができるのですが、確かにunenvがありません。

で、ここからは完全にworkaroundというかたぶんそのうち直ると思われる事象なのですが、Nuxt3の依存関係に指定されているパッケージが、GAEで自動的にインストールされないので、`yarn add` でdependencyに明示的に追加しなければならないようです。

unenvを追加してデプロイしても、また別のパッケージが足りないと言われてエラーが出るので、ひたすら追加していきます。

最終的に私の環境では、`yarn add destr h3 ohmyfetch pathe unenv` で全ての依存関係が解消されました。

改めて`gcloud app deploy`します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/63bc57d1-881f-5205-bd3a-6c6a5254a01d.png)

無事に表示されました！！

Server Date / Client Date についてもちゃんと1秒ズレで表示されています。

## servermiddleware でエッジキャッシュを設定する

本当は冒頭に貼った [catnose さんの記事](https://zenn.dev/catnose99/articles/6442602e9c47d5) のように Cloud CDNを設定したいのですが、この記事の主旨からそれすぎるので、一旦はGAEの基本機能であるエッジキャッシュを活用します。

> レスポンスのヘッダーでCache-Control: public, max-age=86400などと指定するだけでレスポンスがGoogleのエッジサーバにキャッシュされます。

という上の記事の冒頭の記述と、

https://qiita.com/aaharu/items/0e5c0940b6e4892cabee

こちらのNuxt 2のServermiddleware を使ってヘッダーを付与している記事と、

https://v3.nuxtjs.org/docs/directory-structure/server/#server-middleware

公式ドキュメントのServer Middlewareの設定方法を参考に、

```ts:project/server/middleware/response-header.ts
import type { IncomingMessage, ServerResponse } from "http";
export default (
  _req: IncomingMessage,
  res: ServerResponse,
  next: () => void
) => {
  res.setHeader("Cache-Control", "public, max-age=86400");
  next();
};
```

こんなファイルを作成します。自動で読み込まれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/d82fe826-ca06-da6b-e535-49dba8e74a84.png)

localhostでResponse Headerに cache-controll が追加されていることを確認したら、`yarn build && gcloud app deploy`。

再度サーバーにアクセスし、リロードしてみると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/33c689ba-ce70-2182-d932-fef66db2735a.png)

このように2回目のアクセスでは Server Dateが更新されず、Client Dateだけが更新されるようになりました！

また、初回アクセス時には`asyncData` の解決で1秒待たされますが、2回目以降は一瞬でリロードされます。クライアントサイドのみ動作していることがわかります。

## OGP確認

ついでにOGPも確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321625/9954877f-9a9b-7e43-b740-b0cd2e218255.png)

動的ルーティングになっているページもちゃんとOGPが表示されているので、サーバーサイドでレンダリングされていますね。

# 成果物

GitHub: https://github.com/ytr0903/nuxt3-gae-demo （コミットログやReadmeなどは適当なので、あくまで参考程度に）

GAEサイト: https://nuxt3-gae-c.an.r.appspot.com （近いうちに削除する予定です）

# 結論：Nuxt 3のプロダクション運用は現実的か

このように、Nuxt 3のSSRモードをGoogle App Engineで本番運用できる状態にするのは、意外と簡単に進められるということがわかりました。

Nuxt 2 + GAE での開発例は検索するとかなりたくさんあり、基本的にはその知見を活用できるので、ビルドさえできてしまえば、デプロイ時にあまり困ることはないと思います。

これなら本番運用も問題なくできる……と言いたいところですが、**実際にはNuxt 3でちゃんとしたアプリケーションを開発するのはまだまだ大変です**。

この記事のデプロイまでの流れがスムーズに進んだのは、ベーシックな機能しか使っていない、外部ライブラリをdayjsとbulmaくらいしか導入していない、の2点が理由として大きいです。

実はこの記事を書く前から、FirebaseとGraphQL(Apollo Client)を利用したアプリケーションのNext.jsからNuxt 3へのリプレースにもチャレンジしているのですが、

このような複雑なライブラリをNuxt 3で導入しようとすると、途端に開発時・ビルド時・デプロイ後のそれぞれで原因不明のエラーがたびたび発生し、しかもそれがVue・Vite・Nuxt・SSR・rollup……など原因になり得る箇所がたくさんあり、何十時間も苦戦を強いられているのが実情です。

エラーの多くは https://github.com/nuxt/framework/issues で既に提起されていることが多く、調べれば解決策が見つかったりもしますが、修正までに時間がかかったり、アップデートによってまた別の問題が起きたり……と非常に不安定です。

このあたり、Nuxt 2では Nuxt Communityで提供されているモジュールを導入することで、主要なライブラリを導入する際に起きる問題をほとんど回避でき、むしろVueやReact/Nextよりも楽に導入できていたのですが、Nuxt 3はまだほとんどのモジュールが非対応です。

https://modules.nuxtjs.org/?q=3.x&orderBy=desc

逆に言えば、この各種モジュールの対応が進んでいけば自然とここに苦しめられることはなくなっていくので、一過性の問題だとも言えるでしょうし、このあたりの問題を自力である程度解決できる方であれば、本番運用はともかく、その準備として開発をスタートしても問題ないラインには達していると感じました。

# おわりに

現状のNuxt 3はベータ版がようやく公開されたばかりで、既存のバグや未対応の機能もたくさんありますが、それらを差し引いても既に魅力的な開発体験が提供されています。

とにかくNuxt.jsの一番の魅力は「細かい設定なしに開発できるので、プロダクトの価値の提供に集中できる」という部分で、ここはVue/ViteやReact/Nextどと比較しても圧倒的に優れている部分でした。

それがバージョン3になり、Vue 3・Vite・TypeScript・Composition API・TSXといった、新しいスタンダードを基本機能として取り込んだことで、ますます盤石なオールインワンフレームワークになった、という印象を持ちました。

現時点でも十分にその凄さを体感することはできますので、この記事を読んで興味を持った方は、ぜひNuxt 3での開発を試してみてください！