🌅

Co-Edo終了報告サイトをCloudflareで開発するとき使った NuxtHub がとっても便利!

2024/08/16に公開

こんにちは。田中弘治です。
僕は2013年よりコワーキングスペース茅場町 Co-Edoというのを運営してきているのですが、実はまことに残念なことに、ビル建て替えに伴い本年中に終了することとなりました。
これまでご活用いただいたみなさま、本当にありがとうございます。

その報告をする専用サイトを作ろうと思い、どうせならCo-Edoへのメッセージを送れるようなものをと考えてたところで Cloudflare を活用しようと思いました。
D1 や KV といったエッジで動く技術は今後広まっていくと思うので、今後 Cloudflare 中心の開発をしていくための準備でもあります。

作ったもの

1週間も2週間もかけてつくるものではないので、あっさりしたものを作りました。

https://2025.coedo.org/

作っていくうちに、いろいろやりたくなり、結果的につぎのような機能を持つサイトになりました。

  • メッセージを送る機能
  • 今後の最新情報を受け取るためのメール登録
  • コラボレーター募集
  • 寄付募集

いろいろ試しながら作りましたが、数日程度で開発しています。
(といっても管理画面側は公開後につくってます)

技術スタック

基本的にはいつもの Nuxt 3 を中心とした構成です。

  • TypeScript
  • Nuxt3
  • Cloudflare Pages (Hosting & functions)
  • Cloudflare D1 (SQLite)
  • Cloudflare KV (Key-Value Store)
  • Resend (メール送信)
  • Stripe (決済)
  • Sentry
  • その他 Nuxt・Vue.js 関連
    • NuxtHub (後述)
    • Nuxt UI Pro (UI フレームワーク)
    • Nuxt Auth Utils (認証・これから利用)
    • VueEmail (メール送信用のツール・コンポーネント)
  • その他

(太字は今回はじめて使ったもの。Sentry は業務委託での使用を除く)

NuxtHub について

NuxtHub は Cloudflare での開発をサポートするツール・管理画面などをまとめて提供するサービスです。
NuxtLabs が提供しています)

https://hub.nuxt.com/

つぎのような特徴があります。

  • Cloudflare の各種サービスを使ったサイトを効率的に開発できる
  • Cloudflare Pages や D1, KV などの設定・データの確認を管理画面で行える
  • Devtools で開発環境時のデバッグにも対応
  • D1, KV, R2 などを扱うコンポーザブルや Hooks などを提供
  • リモートの DB マイグレーション等を行う CLI 機能を提供

たとえば Vercel や Netlify を活用しているかたは、その管理画面などのUIを Cloudflare で活用できるとイメージするとよいかもしれません。

なお、記事執筆時点ではベータ版です。
(現時点では D1, KV, R2, AI および サーバー側の Cache storage のみサポートされています。今後は認証やメール関連のサービスが追加される予定)

料金は?

Cloudflare の各種技術はほぼ無料やそれに近い低額で利用できます。
(オンボーディング中に従量制のプランにすることを勧める文章はありました。それでも上述のようにほぼ無料で使えるでしょう)

NuxtHub も無料プランもありますが、基本的に有料のツールです。
有料でも月10ドル。プロジェクト数など無制限に使えるので、 Vercel などと比べるとかなりお得だと思います。

僕も無料枠の5プロジェクトに到達したら、すぐ有料にする予定です。

Cloudflare スタックについて

CDNのエッジで動くデータベースをはじめとする Cloudflare が提供する開発者向けの各種サービス群は、とても魅力的です。
これまでの Web 開発の常識を覆すようなものが多いので、これからの Web 開発には欠かせない存在になっていくかもしれません。

たとえば下記の記事のように、Cloudflare スタックでのサービス開発の話も増えてきています。

https://zenn.dev/monicle/articles/cloudflare-stack-ai-service

たくさん記事を書いている Cloudflare の方の記事はとても勉強になります)

一方で Cloudflare 特有の制限などもあるため、まだまだ知見をためていく必要もあるでしょう。
(この手のサービスの無料枠を超える分は総じて負荷の大きい振る舞いといえるので、それらを回避・低減する開発手法の確立・定着が望まれているかと思います)

また Nuxt や Remix のようなメタフレームワークを使用せずとも unjs/nitroHono といった軽量かつ環境に依存しないフレームワークの相性もよいはずです。

これからより広範囲に活用されていくのは間違いないと思います。

NuxtHub ではじめる Cloudflare による Web アプリケーション開発の始め方

個人的には Nuxt スタックとも呼べるほど Nuxt による開発体験は気に入っているのですが、この NuxtHub はその象徴ともいえるものです。

まだ NuxtHub はベータ版ということもあり、試してないという方も多いと思いますので、この記事ではその概要を紹介したいと思います。

Co-Edoの終了告知サイトにおいて、例えばメッセージ投稿機能では、フォーム送信→データベース保存→メール送信を行うといった一連の処理をサーバーサイドのAPIにしています。
Nuxt に慣れていれば /api/foo などでサーバー側の記述ができることをご存知かと思いますが、そのなかで NuxtHub の用意する hubDatabase() 等のコンポーザブルを使用して Cloudflare D1 へのデータ保存というのが基本的な流れです。

NuxtHub で最初のプロジェクトを作成する

NuxtHub のサイト右上の Sign up で GitHub アカウントを使用し NuxtHub のアカウントを作成します。

NuxtHubトップ画面

GitHub アカウントでログインするように求められます。

GitHubログイン

GitHub の認証画面で許可を求められます。

GitHub認証

NuxtHub の管理画面に移ります。
まずは、Cloudflare の API トークンを設定します。

Cloudflare API トークン

まだ Cloudflare のアカウントがない方は、まずはアカウントを作成しましょう。

長いので折りたたみます

Cloudflareアカウント作成

開発者プラットフォームから Cloudflare Pages を選択します。

Cloudflare Pages

無料プランで開始します。

Cloudflare Pages パスワード設定

メールが届くので、メール内のリンクをクリックして Verify します。

Cloudflare Pages メール認証

Workers and Pages を開きます。

Cloudflare Pages ダッシュボード

日本語に変更できます。

Cloudflare Pages 日本語

NuxtHub の管理画面に戻り

Cloudflare API トークン

Create a token with required permissions をクリックします。

Cloudflare API トークン作成
Cloudflare API トークン作成

Account Resources を選択します(All Accounts でもOKです)
Cloudflare API トークン作成 Account Resources
Cloudflare API トークン作成 Account Resources

作成する内容を確認し Create Token をクリックします。

Cloudflare API トークン作成確認
Cloudflare API トークン作成確認

API Token をコピーし、NuxtHub の画面に貼り付けます。
(注意書きのとおり、再表示はできませんので、失くした場合は再作成・再設定となります)

Cloudflare API トークン設定

すべて終わると、自動的に確認が走り、NuxtHub と Cloudflare の連携が完了します。
(Freeプランでも利用可能ですが、1MBのバンドルサイズの制限もあるので、従量制のプランに変更しておくことが強く推奨されています)

Cloudflare API トークン設定完了

プロジェクトを作成する

あとはプロジェクトを作成するだけです。

プロジェクト作成

テンプレートから選択するとすぐにデプロイまでできるため、どのように開発するか理解できます。
(NuxtHub Starter がおすすめです)

プロジェクト作成 テンプレート

お疲れ様でした。

NuxtHub の Slug はアカウントページで変更可能です。

NuxtHub Slug

NuxtHub による開発

インストール

いちからはじめるのであれば下記コマンドでプロジェクトを作成するだけです。

npx nuxthub init my-app

もし、既存のプロジェクトに NuxtHub を導入したい場合は、下記のようにします。

npx nuxi@latest module add hub

設定 nuxt.config.ts

使うサービスを設定します。

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core'],
  hub: {
    database: true,
    kv: true,
    blob: true,
    cache: true,
  },
})

Starter Template を参考にするとよいでしょう。
(実際の挙動も確認できるのでこれをデプロイしておくと便利です)

環境変数を設定

Cloudflare Pages のビルド時やruntimeで使用する環境変数は、開発環境では .env ファイルに記述しておき、本番環境は NuxtHub の管理画面から設定します。
(.env の内容を一括してコピー&ペーストで設定可能なので便利です)

NUXT_FOO_BAR=foobar

なお(Stripe の API キーのような)機密性の高い情報は暗号化して保存することができます。
管理画面からは値入力欄の右側に鍵のアイコンをクリックして設定します(以後値の確認はできません)

デプロイ

指定した Production branch (デプロイ対象のブランチ)が GitHub 上で更新されると自動でデプロイプロセスが開始されます。
(NuxtHub の管理画面でデプロイ状況・デプロイ結果を確認できます。デプロイに要する時間も比較的短いです)

データベースの操作

Cloudflare D1 は SQLite をベースにしたエッジで動くデータベースです。
KV がシンプルな key-value 型のデータを扱うのに対して、D1 は SQL を使って複雑な構造のデータを扱うことができます。

NuxtHub では Drizzle ORM と連携した操作が可能です。
(SQLで扱うことも可能)

(もう何百年もSQLを書いていないので)今回は Drizzle ORM を使います。

Drizzle ORM

基本的な流れはつぎのようになります。

  1. 事前準備: drizzle.config.ts で設定
  2. server/database/schema.ts でスキーマを定義
  3. npm run db:generate でマイグレーションファイルを生成
  4. npm run dev によりマイグレーションが実行される

以後、テーブルのschema変更等は 1~3 を行います。

Drizzle ORM のインストール手順
npm install drizzle-orm drizzle-kit

必要な準備をします。

drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'sqlite',
  schema: './server/database/schema.ts',
  out: './server/database/migrations',
})
package.json
  "scripts": {
    "db:generate": "drizzle-kit generate", // ←追加
  }
server/plugins/migrations.ts
import { consola } from 'consola'
import { migrate } from 'drizzle-orm/d1/migrator'

export default defineNitroPlugin(async () => {
  if (!import.meta.dev) return

  onHubReady(async () => {
    // 開発サーバー起動時にマイグレーションを実行
    await migrate(useDrizzle(), { migrationsFolder: 'server/database/migrations' })
      .then(() => {
        consola.success('Database migrations done')
      })
      .catch((err) => {
        consola.error('Database migrations failed', err)
      })
  })
})

server/database/schema.ts でスキーマを定義します。

server/database/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

// 必要な数だけ記述
export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  password: text('password').notNull(),
  avatar: text('avatar').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
})

server/utils/drizzle.ts で Drizzle を扱う関数と型情報を定義します。

server/utils/drizzle.ts
import { drizzle } from 'drizzle-orm/d1'
export { sql, eq, and, or } from 'drizzle-orm'

import * as schema from '../database/schema'

export const tables = schema

export function useDrizzle() {
  return drizzle(hubDatabase(), { schema })
}

// テーブルを増やすたびに追記
export type User = typeof schema.users.$inferInsert

データの取得

server/api/users.get.ts
export default eventHandler(async () => {
  const users = await useDrizzle().select().from(tables.users).all()

  return users
})

とても簡潔に書けますね。
もちろん呼び出し側では、型情報が付与されたデータを受け取ることができます。

データの挿入

バリデーション用に Valibot を使用します。
(詳細はフォーム作成にて)

下記は適当なサンプル実装です。

server/api/users.post.ts
import * as v from 'valibot'
import { hashSync } from 'bcrypt'
import type { User } from '@@/server/utils/drizzle'
import type { UserSchema } from '@/composables/user'

export default eventHandler(async (event) => {
  const parsedData = await readValidatedBody(event, v.parser(userSchema))

  const user = await useDrizzle()
    .insert(tables.users).values({
      ...parsedData,
      password: hashSync(parsedData.password, 10),
      createdAt: new Date(),
    })
    .returning()
    .get()
    .catch((error) => {
      throw createError({
        statusCode: 500,
        statusContact: 'Failed to insert',
        error,
      })
    })

  return user
})

ほかの例は割愛します。

メール送信

今後 NuxtHub ではメール送信機能も提供される予定です。
今回は Resend を使用しました。

あらかじめ Resend に登録し、API キーを取得しておきます。
(NuxtHub の管理画面から Cloudflare の環境変数を Secret で設定しておきます)

server/api/contact.post.ts
import * as v from 'valibot'
import { sendEmail } from '@@/server/utils/resend'
import type { UserContact } from '@@/server/utils/drizzle'
import { contactSchema } from '@/composables/contact'

export default defineEventHandler(async (event) => {
  try {
    const parsedData = await readValidatedBody(event, v.parser(contactSchema))

    // データベースに保存する
    const insertedData: UserContact = await useDrizzle().insert(tables.contacts).values({
      ...parsedData,
      createdAt: new Date(),
    }).returning().get()

    // メールを送信する
    const subject = `登録ありがとうございます(${insertedData.name}さま)`

    const headers = {
      'X-Entity-Ref-ID': `${insertedData.id}`,
      'X-Entity-Ref-Time': `${insertedData.createdAt}`,
    }
    await sendEmail({
      from: 'no-reply@coworking.tokyo.jp',
      to: insertedData.email,
      subject,
      text: buildMail(insertedData),
      headers,
    })

    return {
      success: true,
      error: null,
    }

  } catch (error) {
    const message = error instanceof Error ? error.message : ''
    throw createError({
      statusCode: 400,
      statusContact: `post message error: ${message}`,
    })
  }
})

const buildMail = (data: UserContact) => `
${data.name} さま

以下の内容でメッセージをお預かりしました。

-------------------------------------
■ 氏名: ${data.name}
■ メールアドレス: ${data.email}
-------------------------------------

--
コワーキングスペース茅場町 Co-Edo
`.trim()
server/utils/resend.ts
import { Resend } from 'resend'
import type { CreateEmailOptions } from 'resend'

let resend: Resend

const useResendClient = () => {
  if (!resend) {
    resend = new Resend(useRuntimeConfig().resendKey)
  }
  return resend
}

export const sendEmail = async (options: CreateEmailOptions) => {
  const resend = useResendClient()

  const results = await resend.emails.send({
    ...options,
  }).catch((error) => {
    throw new Error(error)
  })
  // 送信終了
  return results
}

VueEmail を使用する

サーバー側でも Vue のコンポーネントを使用したいところです。
調べたところ、いちから書くと下記のようになります。

サーバーサイドで Vue コンポーネントを使用する
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import { createViteServer } from 'vite'
import vue from '@vitejs/plugin-vue'

let vite

export default defineEventHandler(async (event) => {
  if (!vite) {
    vite = await createViteServer({
      server: { middlewareMode: true },
      appType: 'custom',
      plugins: [vue()],
    })
  }

  const { default: MyComponent } = await vite.ssrLoadModule('/path/to/MyComponent.vue')

  const app = createSSRApp(MyComponent, {
    // コンポーネントに渡す props
    prop1: 'value1',
    prop2: 'value2',
  })

  const html = await renderToString(app)
  // ここでhtmlを使ってメールを送信するなどの処理を行う

  return html
})

となるのですが VueEmail がほぼ同じように実装していたので、HTMLメールで使用できるコンポーネントも用意されていたこともあり活用しました。

そんなわけで、下記のようにメール送信が可能です。

server/api/contact.post.ts
# 該当箇所のみ
    // メールを送信する
    const subject = `[Co-Edo]: ご登録ありがとうございます(${insertedData.email}`
    const html = await render(RegisterMail,{
      title: subject,
      data: insertedData,
    })

    const results = await sendEmail({
      from: 'no-reply@coworking.tokyo.jp',
      bcc: 'info@coworking.tokyo.jp',
      reply_to: insertedData.email,
      to: insertedData.email,
      subject,
      html,
    })

フォームとバリデーション

全体的な UI は Nuxt UI (Pro) を使用しました。

Nuxt UI の記事はこちらをどうぞ。
https://zenn.dev/comm_vue_nuxt/articles/setup-nuxt-ui-and-nuxt-ui-pro

基本的には任意のコンポーネントに対し :ui props で適用したいスタイルを Tailwind 記法で渡します。

<script setup lang="ts">
// 初期値はドキュメントで確認できる
const ui = {
  title: 'text-lg font-bold',
  description: 'mt-1 text-lg',
}
</script>
<template>
  <UAlert
    title="アラートはこんな感じ"
    color="primary"
    :ui
  />
</template>

メールフォームは <UForm> でバリデーション等を扱うことができます。
Yup, Zod, Joi, Valibot などを扱いやすく作られています。
今回は Valibot を使用しました。

<script setup lang="ts">
import * as v from 'valibot'
import type { FormSubmitEvent } from '#ui/types'

const schema = v.object({
  email: v.pipe(v.string(), v.email('Invalid email')),
  password: v.pipe(v.string(), v.minLength(8, 'Must be at least 8 characters'))
})

type Schema = v.InferOutput<typeof schema>

const state = reactive({
  email: '',
  password: ''
})

const submit = async (event: FormSubmitEvent<Schema>) => {
  const result = await $fetch('/api/login', {
    method: 'POST',
    body: event.data,
  }).catch((error) => {
    console.error(error)
  })

  if (result) {
    await navigateTo('/dashboard')
  }
}
</script>

<template>
  <UForm
    :schema="v.safeParser(schema)"
    :state
    class="space-y-4"
    @submit="submit"
  >
    <UFormGroup label="Email" name="email">
      <UInput v-model="state.email" />
    </UFormGroup>

    <UFormGroup label="Password" name="password">
      <UInput v-model="state.password" type="password" />
    </UFormGroup>

    <UButton type="submit">
      Submit
    </UButton>
  </UForm>
</template>

NuxtHub 管理画面

最後に NuxtHub の管理画面について簡単に紹介します。

Cloudflare Pages は GitHub への push をトリガーに自動でデプロイを行いますが、NuxtHub はそのデプロイの状況を確認することもできます。

NuxtHub管理画面

Database の操作や、管理コンソールもあります。

NuxtHub管理画面

NuxtHub管理画面

ちなみにこの UI は Nuxt UI の Dashboard で作られています。
有料版のユーザーは利用できますので、アプリケーションの管理画面はこれでつくると工数が削減できますね。
(まさにいま少しずつ作っているところです)

最後に

NuxtHub は Cloudflare のサービスを使った開発をサポートするツール・サービスです。
使ってみるとわかりますが、BaaS を使っているかのような感覚で、とても便利に開発可能です。

Cloudflare を使った開発に興味のあるフロントエンド開発者は、ぜひ試してみてください。

おまけ

今回さくっと数日で作ったのが、Co-Edoの終了告知サイトです。
Stripe を使った寄付もできるようになっています。
ぜひ試してみてください 😎

https://2025.coedo.org/

(あとバグ報告がありましたら、ぜひともコメントやメッセージでお知らせくださいませ 🙇‍♂️)

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion