【Next.js × @vercel/og】セミナー登壇者風に誰でも予定を告知できるアプリを作りました

# はじめに

突然ですがみなさん、 X（旧Twitter）でこんな感じのイベント告知画像を見たことはありますか？

<br />

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/8cd72866-ce26-8816-f912-1f00e388a289.png" width=50% /><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/5046da8a-1668-1816-a731-e8269adecbec.png
" width=50% />

<br />

最近はセミナーや講演会に登壇する人たちが、イベント告知のためにこのような投稿をすることが増えているように感じます。

そして、こうした投稿を目にするたびに、自分は次のようなことを思います。

<br />

### <font color="Coral">人生で １ 回でいいから！！こんな感じで予定を告知してみたい！！なんかすごそう！！</font>

<br />


そんな（超どうでも良い個人的な）欲求を叶えるため、**「誰でもセミナー登壇者風に予定を告知できるアプリ」** を作りましたので、簡単に紹介したいと思います。

（本当は[クソアプリ Advent Calendar](https://qiita.com/advent-calendar/2023/kuso-app)に投稿したかったけど、諸々の事情により断念）

# 作ったもの

[Seminar Post Generator](https://semipos.vercel.app/)という **「セミナー登壇者風に個人的な予定を告知できるWebアプリ」** を作りました。

<br />

▼**サービスURL**

https://semipos.vercel.app/

▼**リポジトリ**

https://github.com/youliangdao/self-motivate-image-generator

<br />

いくつかの項目を入力するだけで、以下のような「セミナー登壇者風アイキャッチ画像」を作成し、SNS などで共有することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/1cd163f0-26cc-00a5-f063-71d42092e0be.png)

# 使い方

基本的には**各ステップごとの入力項目に入力していくだけです。**


## Step1: ジャンル選択
まずは告知したい予定のジャンルを選択します。

全部で14種類ほど用意していますので、予定に合わせて選択してください。

<br />

<img src="https://i.gyazo.com/cb02b76a6cf847b9c2c3db856b87b2f8.gif" width=80% />


## Step2: テンプレート選択
次にお好みのテンプレートを選択してください。

セミナー登壇者が使用しそうなアイキャッチを用意しておりますので、お好みに合わせて選択してください。

<br />

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/5e0a9abf-dfb8-b7b9-5548-2a5c895b838f.png" width=80% />



## Step3: 必要な情報（タイトルや時間帯など）を記入

次に告知したい予定の詳細を以下のフォームに入力していきます。

**アスタリスクがついている部分は必須項目です。** またアイキャッチ画像の都合上 **「タイトルは15文字以内」「補足説明は30文字以内」** となっています。

<br />

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/1d45e180-65f0-2b7e-6733-9403a78fae3e.png" width=80% />


## Step4: プレビュー画面で、完成形を確認 → SNSなどで共有
プレビュー画面で完成形を確認します。

そして問題がなければ共有画面に移動し、X（旧 Twitter ） などで共有します。

<br />

<table>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/19e62c16-fc88-a9ee-3ae2-8c76d5135987.png" /></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/fd393f14-5531-733c-b58d-f92bec7a5446.png" /></td>
</tr>
</table>




# 作るまでの過程

ここからは「どういった流れで作っていったのか？」をてきとーに説明していきます。

## きっかけ
1ヶ月前くらいに X（旧Twitter）のOGP画像に仕様変更がありました。

https://www.buzzfeed.com/jp/kenjiando/x-ogp

https://x.com/catnose99/status/1709746235392086056?s=20

https://x.com/fladdict/status/1709727104827265059?s=20

多くの人から「クソ仕様」と批判が寄せられていたこの仕様変更ですが、 **「逆にOGP内に情報を詰め込んでTwitterに投稿できるようなアプリを作ってみたら面白そう」** と思い作ってみることにしました。

あとは冒頭でもお伝えしましたが、 **「1度でいいから有名人っぽく予定を告知してみたいよなぁ...」** と思ったのもきっかけの１つです。


## ざっくり開発スケジュール
実稼働日としてはだいたい **3~4日くらい** です。
（平日の仕事終わりと週末を用いて実装しました）

### 1日目

1日目は **「そもそも何を作るのか？」** ということ自体がちゃんと定まっていなかったので、Zennのスクラップ上でざーっとアイデアを出していきました。

アイデアが定まったら、今回は技術などに特にこだわりがなかったため（新しい技術の素振りみたいな目的ではない）、**Next.js などの慣れた技術を使って諸々のセットアップを行っていきました。**

https://zenn.dev/youliangdao/scraps/755ba7f93b1624

▼セットアップ周りで重宝した記事

https://zenn.dev/www_y/articles/58d6bb1da8a723

### 2日目

2日目は核となるアイキャッチ画像の作り込みを始めました。

具体的には **Canva を使って、「セミナー」といったワードでテンプレートを検索したり、connpass 上でそれっぽいアイキャッチを使用しているイベントを探したりと地道な努力をしながら、画像を作り込んでいきました。**

この際には（後述する）`@vercel/og` 専用の Playground とにらめっこしながら、ひたすらレイアウトを組んでいた気がします。（以下サンプル）

https://og-playground.vercel.app/?share=jVlbjxtJGf0rLbMoiRjb1VXVNysbCQxIgQCrDQgJ5SH2uMfT2fZFdk88w2ikzAQECg-R0LI8wMM-sLwAjwjEIvFfMMNqn_gLnO9Snkvs7I527eq6fbdzTpU7p6392ahs9VoR_u6PqudPptSiv2VzUpfvnp5e9dDfcLD_wXgxO5qOHlc_LXvRndiYr0b0cWdv18z3ZsuqqWZTzN4vp0252D31_XJeDhpMnM7aC27vnvtwMhjDg6dHi_ruO6dPR4Nm0Kuor7t8Pv7a8aTee-e0nFJ8P3r_YX82mc-msH735nZRdOc-pkfV6N0nrefV8mhQP2lFz6ty9Y3ZMbpMZKLYGhOlzmBgVY2aQ3RTFx4Py2p82OBZRmFzusTTYdPMe93uarXqrFxnthh3Md2QW2FS77iuph9snRoXRdHlYXKkXCyRObLYiZ-0HtyfD5rDiHz9nomK9FGcdyyy7zuu79KOi-LYdrIoo4_YFtRhCrR8P_YJD-c0ksf4LGJ02DiPcte3CXaIMo9uWxh85jTbWTSKToKtHR4LhwFXFGGtd2mUZ30Pay7KyWRiaPuMHElgz3Kzn7A57k1hX-emMJlHhe3YfsrbxYan2Yw8jNmf1FPuO1k_N4ZmkD8Ik2JDK-OefmE4TvQUsCljRY6sxPC0j13JJOWFTMAoRtmEKeIoR_r6GMvIGqcmRqjwgswjWTFcRHj2EUMgM_KNLxkyWMvzgRHZBA3aFwNiip7ZAVQLPpm--Ign9tpwFKYvUeGJ44QlRG76kgtAj_ODb06Z6UsWTSSJxTfn2vQl_SaSiuAbBTJ9qZiJpIj4Rn5MX8psIqk8vgEEeK07Clo4GPZDN5A-gpx5hDT8BAA9qOoaaPxKGhf5wQEhtEsQvQnUOCDVxkibINVycLy1TQJSLRKPgrB1mwO57I_N8T_8s8BQoli1aapQtR6oREyWKqFQtTnlUSJ3jmxQLpzNsV6y4yztIhnjlZpFmxAiJbPW0_6SbQt4AL9cAZvBX0WtWJJKWU8lp9pxS1ErvVJhmU01twVBT1Dg4lSxyy3gxAG7yINAx9lr2KUYFLzOwoGAXheDKwJGW2ywa0G1REBrM0Vxmgf4ZobRL4hnG8oEphjzI2U3hDVMrkAnikNYhsgV1bFNFNfUEn4mim3uURYnwmzSIsW40FMyykVTdSAHVTOoFKokXDnRFxI-VR3qVS2iuaxOkC7eDiqGFImK0RasbBmBSdSOMSMayIAkVYyVDbw2yKdmBkFYoYVmX7SX9lJFhh-gSOGvc8Tt52YnR2wRC0dcbIUhzqk7LkvYqCNnlR3esEn20HFpyGkHZObKEEc45PCcFy13wHYQc5fCEufGGyvsACLzwA7eURLrPCVA8s1tZYf0S3VkvhTNG7fRdM8pkRqTb6Lpjrik7HAZ5U8wQjGJrnvKhLLD4xwL2u4tlxC485a0QOEoUxSn3gtdAF-EFfihUwTsng7MwBFHiBBqwMVAlo3GE2VhKFArDiRBmZSG4CpoGOgpCFHiBpY4sATxKMe9soRaogVEM1UI1pmgHHyYs56gpVyRXlWfG6oEt5QtpJSomaiYoyoGdSNGBdWj0aCG9CkaSXkNysnoEUVlS6KzWKfEsT5R3ljykjXaU7pUt4Fb1XKhDUQI9lX0N8SxltWDDwgkJgdx4pvMMSZNdzLHJV6Zk5H-MzlwhLLq-5TtepwwG-ZkFI946Llo5Lf3OBM5Eg9vqCYsIriHiFp46C7dsWgtEBa4k8sp65HvwB3aP3CHUBe441G9cLLAhjLHp2gJb9IksAZ9UmuaR_X3qERgjHeULWUMBFrOkwSKExiTIB7lS1EIW3DP2tyFZFFgC40oW2SS8kX6lS-yQgjhoZZ6lCD8QBwwSOkEvaRdlGjXjhVKQDDlcCMNdOVDIRCZpgi_CUeB9XwuqBrQDNGIK9YQodGrikL1E52h62dQH3JB88u1CWoVb84YxzfcoG40P6geMSuoIYeiKskxsnZS_YOehruWS-W25cC6cNQ4HytjSIFFszOsDUpOOwaFZ5Ei3cc1ldjPYShnnIMSyXFBdzFclm7yJSlGox18SZ2eNGgIX9AQvlCDLNKIeME97Bu1yF8akwi4h6OiFkUqO1Ls3CNHOVqUIxqTvFGPZJNbnGMalcxTn9SDW1wlGpXaiatUUW6hyjQmlacewQO3kCoaE9xQj6CJW0AYxyiwk7AZjpoBGKZhAS73CaClyUDnNMovQ234bHO18vjc3K18LseNEotvCUI4Ps8DEaVfKUorhLk0J7AZ3FCG008vYj3pedACWhM0gu90pBysW0FNNgxIw6mBlqKf-oI6XWkWPhX5xLyAfJ_xPFY-7BKQnwHNqpCCfDql8etStVRkXzSWzxTSXRew73E72WAf-wv2PY4Jxb73oDRjv8gV-1kC7ONacxP7fn84vIZ9-t394PZrhHtnT8_uPb3VKT_n9aXG7RX803_H2KhazuvBCUYP6vL49mhTHjdfr6vx7hcgAxp92JST5c4pz46WTXVw0p9hcEo-bp9G9r9ZLcr98MJlVh9Npttm_XgxmPOLlhUab0yAmW8PJlVNMX1_1syix4PpMvrOe3vRk9YSzfayXFQ4ke9crTs7C-0HV530TilqVigM2Ywmg-P2YfvgqK6j-XE7QZWu2701mT7aCCCajHrcXsxW0UpXn7RxAcMenkYrSl1bMhIy1R6Wzaosp7ds7LZCZWq745qDbw9n9SiaNG3DPq_aWTe2b2yFzZbzwVR24_WrQ_iy2QoLPvvbq_---OTzj351efHn__3zHP8BkFjz5k5z2WY4vr6Jhz_wIo8mw7ZD8L6byEBdHjTYfn3x1_XLj9YX_1pf_GH98pfrl39aX_z68uefXL763fr8t-vzP67PX6_PP16f_2Z9_jMw4gsDGNZHZbuAmt30IyM_OBGc_o0LUV0ORtV03G6IOuzQl3RlexJu1ubKdNPWuMUndeN63bcU5y2Iatox3thtWfEmZGtM3XiyY00Eee8k20duZziUFFvdfXw0vbcjFby0C1-2hbWr_23QTsgugkk4-m1g3uEtZsZJD5BYv_h9FKdovc3lW-uJULDnsMm_X3z6-YsP__P3v1y-vrh89fH6JR5fXP7iHz_47s79tge6rXfHzB0JuSEYQPp4MThp8zvdbU5Uk_G2UJeLfX2Du8QrXBwA--UhVKPzbN7FJfDY4SieT_HKd9vaQU2vjT_78NPLl6-3zwivmrHV9gmbl88wtH3GQUXD-3JkbJvS_RKpvd1z8zk8tfZaszmdOctW77TFvrd6dCvaa4mfrR7eke-1RuXwaNzqHQzqZbnXKiezZ9UPT-b0LxAQa3rCRqS_35oMy5HOO9trNYMhphyWdT1bzRb1qHX2fw


正直ここで最も時間を食った気がしています。

例えば`<div>` が複数の子ノードを持つ場合にはスタイルの工夫が必要だったり（`flex` を明示的に記述する必要があった）、`/public` ディレクトリ配下のプロジェクトローカルの画像ファイルを出力できなくて、一旦画像ファイルをメモリに読み込んでdataURLに変換するといったワークアラウンドが必要だったりと色々とクセが強かったです。[^1]

[^1]: [「@vercel/og を使ってブログに動的 og:image 生成を実装した」](https://blog.stin.ink/articles/add-vercel-og#%E7%94%BB%E5%83%8F%E3%81%AE%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%81%AB%E5%B7%A5%E5%A4%AB%E3%81%8C%E5%BF%85%E8%A6%81)を参考

:::note info
`@vercel/og` は JSX でスタイルを定義することができるのですが、すべての css プロパティが使えるわけではなかったため意外と苦労しました。
（なお `TailwindCSS` は利用できます。）

▼使用可能なCSSプロパティ
https://github.com/vercel/satori

背景画像については、以下のサイトを元に適当に生成しました。
（パラメータなどを調整すれば、いい感じに作ってくれます）

https://app.haikei.app/
:::


### 3~4日目
最後にアプリの UI を作り込んでいきました。

ここはいつもお世話になっている [Mantine](https://mantine.dev/) などを利用して爆速で実装しました。

https://mantine.dev/

Mantine の１つの特徴として「コンポーネントが豊富」ということが挙げられますが、**今回はそれ以外にも`useLocalStorage` などに代表される [@mantine/hooks](https://www.npmjs.com/package/@mantine/hooks) にも非常に助けられました。**

アプリの仕様上、Local Storage 上に入力フォームのデータをもたせたかったため使用しましたが、（型が若干おかしいこと以外は）使用していて特に違和感は感じませんでした。

また Mantine はリリース頻度が高いことでも有名で、**2ヶ月ほど前に v7 がリリースされて内部のCSSが`emotion` から `native CSS` に置き換えられ、App Router にもすでに対応しております。興味ある人はぜひ使ってみることをおすすめします！**

（どこかの「ライブラリ側で `use client` 付けただけのもの」とは大違い）

https://mantine.dev/changelog/7-0-0/

# 技術スタックなど

Next.js (App Router) / TypeScript / Vercel で開発、デプロイをしました。

現在携わっている業務と同じような技術を用いることで、新しいキャッチアップが少なく済むと感じこのような選択となりました。

また、アイキャッチ画像の生成にはおなじみの `@vercel/og` を使ったEdge Function上で動的OGP画像を出力する仕組み（**Vercel OG Image Generation**）を用いています。


https://vercel.com/docs/functions/edge-functions/og-image-generation

## `@vercel/og`について

一応 `@vercel/og`を知らない人向けに簡単にご紹介しておきます。（知っている人は飛ばしてください）


`@vercel/og` は、Vercel が 2022 年の 10 月に公開した、**Edge Function 上で OGP 画像を生成するためのライブラリです。**

OGP画像は HTML と CSS（というか JSX ）を使用して定義することができるので、React に慣れていればすぐに使い始められます。

**コアエンジンには [satori](https://github.com/vercel/satori) という HTML と CSS を SVG に変換するライブラリが使われています。**

▼以下簡易的な仕組み

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/87e78294-0681-9d5d-9659-ac47ae0a5709.png" width=60% />

これにより、簡単に OGP を実装することが可能になります。

## @vercel/ogを使う上で工夫した点と注意点 

次に実際に `@vercel/og` を使っていく中で苦労した点などについて軽く触れておきます。

### フォントファイルのサイズを小さくする

まず多くの人が直面するのがこの問題でしょう。

`@vercel/og` がデフォルトで読み込む font ファイルは `noto-sans-v27-latin-regular.ttf` であり、**日本語をいい感じに描画するためには Google Fonts などを活用してカスタムフォントを適用させる必要があります。**

▼以下デフォルトのフォント。なんかダサくない？

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/ba56eece-46cf-02f1-44e7-563cda11f2a2.png" width=70% />


しかしながら Vercel Edge Functions 上では Hobby プランだとデプロイサイズが 1MB までに制限されていて、**日本語フォントはひらがな、カタカナ、漢字を含むため、ファイルサイズが数 MB〜十数 MB になることも珍しくありません。** 例えば Google Fonts から提供されるNotoSansJP-Regular.otf のファイルサイズは **4.5MB** あります。

https://vercel.com/docs/functions/edge-functions/limitations



そのためファイルサイズを事前に減らしてデプロイする必要があり、**「フォントのサブセット化」といったことを一般的には行います。**

これは簡単に言うと、**あるフォントファイルのうち必要な文字のフォントデータだけを抽出して新しいフォントファイルを生成する処理の事をいいます。**

<br />

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/9bc1029b-5f95-c07d-e5d6-d84f8a071172.png)

<br />

生成されたフォントファイルは必要な文字のフォントデータしか含まれていないため、オリジナルのフォントファイルと比べてファイルサイズが小さくなります。

例えば、[サブセットフォントメーカー](https://opentype.jp/subsetfontmk.htm)と呼ばれるツールを用いて常用漢字、ひらがな、カタカナ、英数字、記号を残してそれ以外を削除してみます。

```
亜哀挨愛曖悪握圧扱宛嵐安案暗以衣位囲医依委威為畏胃尉異移萎偉椅彙意違維慰遺緯域育一壱逸茨芋引印因咽姻員院淫陰飲隠韻右宇羽雨唄鬱畝浦運雲永泳英映栄営詠影鋭衛易疫益液駅悦越謁閲円延沿炎宴怨媛援園煙猿遠鉛塩演縁艶汚王凹央応往押旺欧殴桜翁奥横岡屋億憶臆虞乙俺卸音恩温穏下化火加可仮何花佳価果河苛科架夏家荷華菓貨渦過嫁暇禍靴寡歌箇稼課蚊牙瓦我画芽賀雅餓介回灰会快戒改怪拐悔海界皆械絵開階塊楷解潰壊懐諧貝外劾害崖涯街慨蓋該概骸垣柿各角拡革格核殻郭覚較隔閣確獲嚇穫学岳楽額顎掛潟括活喝渇割葛滑褐轄且株釜鎌刈干刊甘汗缶完肝官冠巻看陥乾勘患貫寒喚堪換敢棺款間閑勧寛幹感漢慣管関歓監緩憾還館環簡観韓艦鑑丸含岸岩玩眼頑顔願企伎危机気岐希忌汽奇祈季紀軌既記起飢鬼帰基寄規亀喜幾揮期棋貴棄毀旗器畿輝機騎技宜偽欺義疑儀戯擬犠議菊吉喫詰却客脚逆虐九久及弓丘旧休吸朽臼求究泣急級糾宮救球給嗅窮牛去巨居拒拠挙虚許距魚御漁凶共叫狂京享供協況峡挟狭恐恭胸脅強教郷境橋矯鏡競響驚仰暁業凝曲局極玉巾斤均近金菌勤琴筋僅禁緊錦謹襟吟銀区句苦駆具惧愚空偶遇隅串屈掘窟熊繰君訓勲薫軍郡群兄刑形系径茎係型契計恵啓掲渓経蛍敬景軽傾携継詣慶憬稽憩警鶏芸迎鯨隙劇撃激桁欠穴血決結傑潔月犬件見券肩建研県倹兼剣拳軒健険圏堅検嫌献絹遣権憲賢謙鍵繭顕験懸元幻玄言弦限原現舷減源厳己戸古呼固孤弧股虎故枯個庫湖雇誇鼓錮顧五互午呉後娯悟碁語誤護口工公勾孔功巧広甲交光向后好江考行坑孝抗攻更効幸拘肯侯厚恒洪皇紅荒郊香候校耕航貢降高康控梗黄喉慌港硬絞項溝鉱構綱酵稿興衡鋼講購乞号合拷剛傲豪克告谷刻国黒穀酷獄骨駒込頃今困昆恨根婚混痕紺魂墾懇左佐沙査砂唆差詐鎖座挫才再災妻采砕宰栽彩採済祭斎細菜最裁債催塞歳載際埼在材剤財罪崎作削昨柵索策酢搾錯咲冊札刷刹拶殺察撮擦雑皿三山参桟蚕惨産傘散算酸賛残斬暫士子支止氏仕史司四市矢旨死糸至伺志私使刺始姉枝祉肢姿思指施師恣紙脂視紫詞歯嗣試詩資飼誌雌摯賜諮示字寺次耳自似児事侍治持時滋慈辞磁餌璽鹿式識軸七叱失室疾執湿嫉漆質実芝写社車舎者射捨赦斜煮遮謝邪蛇尺借酌釈爵若弱寂手主守朱取狩首殊珠酒腫種趣寿受呪授需儒樹収囚州舟秀周宗拾秋臭修袖終羞習週就衆集愁酬醜蹴襲十汁充住柔重従渋銃獣縦叔祝宿淑粛縮塾熟出述術俊春瞬旬巡盾准殉純循順準潤遵処初所書庶暑署緒諸女如助序叙徐除小升少召匠床抄肖尚招承昇松沼昭宵将消症祥称笑唱商渉章紹訟勝掌晶焼焦硝粧詔証象傷奨照詳彰障憧衝賞償礁鐘上丈冗条状乗城浄剰常情場畳蒸縄壌嬢錠譲醸色拭食植殖飾触嘱織職辱尻心申伸臣芯身辛侵信津神唇娠振浸真針深紳進森診寝慎新審震薪親人刃仁尽迅甚陣尋腎須図水吹垂炊帥粋衰推酔遂睡穂随髄枢崇数据杉裾寸瀬是井世正生成西声制姓征性青斉政星牲省凄逝清盛婿晴勢聖誠精製誓静請整醒税夕斥石赤昔析席脊隻惜戚責跡積績籍切折拙窃接設雪摂節説舌絶千川仙占先宣専泉浅洗染扇栓旋船戦煎羨腺詮践箋銭潜線遷選薦繊鮮全前善然禅漸膳繕狙阻祖租素措粗組疎訴塑遡礎双壮早争走奏相荘草送倉捜挿桑巣掃曹曽爽窓創喪痩葬装僧想層総遭槽踪操燥霜騒藻造像増憎蔵贈臓即束足促則息捉速側測俗族属賊続卒率存村孫尊損遜他多汰打妥唾堕惰駄太対体耐待怠胎退帯泰堆袋逮替貸隊滞態戴大代台第題滝宅択沢卓拓託濯諾濁但達脱奪棚誰丹旦担単炭胆探淡短嘆端綻誕鍛団男段断弾暖談壇地池知値恥致遅痴稚置緻竹畜逐蓄築秩窒茶着嫡中仲虫沖宙忠抽注昼柱衷酎鋳駐著貯丁弔庁兆町長挑帳張彫眺釣頂鳥朝貼超腸跳徴嘲潮澄調聴懲直勅捗沈珍朕陳賃鎮追椎墜通痛塚漬坪爪鶴低呈廷弟定底抵邸亭貞帝訂庭逓停偵堤提程艇締諦泥的笛摘滴適敵溺迭哲鉄徹撤天典店点展添転塡田伝殿電斗吐妬徒途都渡塗賭土奴努度怒刀冬灯当投豆東到逃倒凍唐島桃討透党悼盗陶塔搭棟湯痘登答等筒統稲踏糖頭謄藤闘騰同洞胴動堂童道働銅導瞳峠匿特得督徳篤毒独読栃凸突届屯豚頓貪鈍曇丼那奈内梨謎鍋南軟難二尼弐匂肉虹日入乳尿任妊忍認寧熱年念捻粘燃悩納能脳農濃把波派破覇馬婆罵拝杯背肺俳配排敗廃輩売倍梅培陪媒買賠白伯拍泊迫剝舶博薄麦漠縛爆箱箸畑肌八鉢発髪伐抜罰閥反半氾犯帆汎伴判坂阪板版班畔般販斑飯搬煩頒範繁藩晩番蛮盤比皮妃否批彼披肥非卑飛疲秘被悲扉費碑罷避尾眉美備微鼻膝肘匹必泌筆姫百氷表俵票評漂標苗秒病描猫品浜貧賓頻敏瓶不夫父付布扶府怖阜附訃負赴浮婦符富普腐敷膚賦譜侮武部舞封風伏服副幅復福腹複覆払沸仏物粉紛雰噴墳憤奮分文聞丙平兵併並柄陛閉塀幣弊蔽餅米壁璧癖別蔑片辺返変偏遍編弁便勉歩保哺捕補舗母募墓慕暮簿方包芳邦奉宝抱放法泡胞俸倣峰砲崩訪報蜂豊飽褒縫亡乏忙坊妨忘防房肪某冒剖紡望傍帽棒貿貌暴膨謀頰北木朴牧睦僕墨撲没勃堀本奔翻凡盆麻摩磨魔毎妹枚昧埋幕膜枕又末抹万満慢漫未味魅岬密蜜脈妙民眠矛務無夢霧娘名命明迷冥盟銘鳴滅免面綿麺茂模毛妄盲耗猛網目黙門紋問冶夜野弥厄役約訳薬躍闇由油喩愉諭輸癒唯友有勇幽悠郵湧猶裕遊雄誘憂融優与予余誉預幼用羊妖洋要容庸揚揺葉陽溶腰様瘍踊窯養擁謡曜抑沃浴欲翌翼拉裸羅来雷頼絡落酪辣乱卵覧濫藍欄吏利里理痢裏履璃離陸立律慄略柳流留竜粒隆硫侶旅虜慮了両良料涼猟陵量僚領寮療瞭糧力緑林厘倫輪隣臨瑠涙累塁類令礼冷励戻例鈴零霊隷齢麗暦歴列劣烈裂恋連廉練錬呂炉賂路露老労弄郎朗浪廊楼漏籠六録麓論和話賄脇惑枠湾腕ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをんァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶ　 、。，．・：；？！゛゜´｀¨＾￣＿ヽヾゝゞ〃仝々〆〇ー―‐／＼～∥｜…‥‘’“”（）〔〕［］｛｝〈〉《》「」『』【】＋－±×÷＝≠＜＞≦≧∞∴♂♀°′″℃￥¥＄￠￡％＃＆＊＠§☆★○●◎◇◆□■△▲▽▼※〒→←↑↓〓∈∋⊆⊇⊂⊃∪∩∧∨￢⇒⇔∀∃∠⊥⌒∂∇≡≒≪≫√∽∝∵∫∬Å‰♯♭♪ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя─│┌┐┘└├┬┤┴┼━┃┏┓┛┗┣┳┫┻╋┠┯┨┷┿┝┰┥┸╂｡｢｣､･ｦｧｨｩｪｫｬｭｮｯｰｱｲｳｴｵｶｷｸｹｺｻｼｽｾｿﾀﾁﾂﾃﾄﾅﾆﾇﾈﾉﾊﾋﾌﾍﾎﾏﾐﾑﾒﾓﾔﾕﾖﾗﾘﾙﾚﾛﾜﾝﾞﾟ①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ≒≡∫∮∑√⊥∠∟⊿∵∩∪ＡＢＣＤＥＦＧＨＩＪＫＬＭＮＯＰＱＲＳＴＵＶＷＸＹＺａｂｃｄｅｆｇｈｉｊｋｌｍｎｏｐｑｒｓｔｕｖｗｘｙｚ０１２３４５６７８９abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !"#$%&'()-^\@[;:],./\=~|`{+*}<>?_
```

すると4.5MBほどあったフォントファイルは自分の場合は`700KB` ほどになりました。

<br />

**ただ一般的に用いられるこの方法では、どうしても 1MB のサイズ制限に引っかかってしまいデプロイできなかったため、自分の場合はサブセット化とは別の対応策をとりました。**

:::note info
以下記事などを読むとサブセット化のみで無事デプロイできたと書いてありましたが、自分はどうしても無理でした😇

[【デザイン不要】@vercel/ogを使っていい感じの動的OGPをつくる](https://zenn.dev/hiromu617/articles/c03fef6f4d6c6e#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B)
:::

それが以下の **「Google FontsのCSSからフォント部分を抜き出してfetchする」** という方法[^2]です。

[^2]: こちらの方法は、[Satori Playground](https://github.com/vercel/satori/blob/2e8dcb486f3dadeb6fc2e8790cb822a72893a21a/playground/pages/api/font.ts#L86-L111) や [next/font](https://github.com/vercel/next.js/blob/canary/packages/font/src/google/find-font-files-in-css.ts)などでも用いられている方法みたいです。

詳細は参考記事などを見てほしいのですが、簡単に説明すると以下の手順を踏んで**フォントを動的に読み込みます。**

1. fetch APIを用いて**指定された Google Fonts の URL にリクエストを送る**
1. `text()`を用いて CSS テキストを取得する
1. そこに埋め込まれているフォントリソースの URL を**正規表現を用いて抽出する**
1. 最後にフォントファイルをダウンロードして **ArrayBufferとして返す[^3]**

[^3]: https://developer.mozilla.org/ja/docs/Web/API/Response/arrayBuffer

```tsx: src/lib/font.ts
export async function loadGoogleFont({
  family,
  weight,
  text,
}: {
  family: string
  weight?: number
  text?: string
}) {
  const params = new URLSearchParams({
    family: `${family}${weight ? `:wght@${weight}` : ''}`,
  })
  if (text) {
    params.append('text', text)
  } else {
    params.append('subset', 'latin')
  }

  // Google Fonts の URL を組み立てる
  const url = `https://fonts.googleapis.com/css2?${params.toString()}`
  // CSS テキストを読み込む
  const css = await fetch(url).then((res) => res.text())
  // フォントリソースの URL を抽出
  const fontUrl = css.match(
    /src: url\((.+)\) format\('(opentype|truetype)'\)/,
  )?.[1]

  if (!fontUrl) {
    throw new Error('Font file not found in CSS fetched from Google Fonts')
  }
  // リソースが存在すれば、ArrayBufferとして返却する
  return fetch(fontUrl).then((res) => res.arrayBuffer())
}

```
<br />

▼以下参考記事です。

https://zenn.dev/temasaguru/articles/2968736b5a2f41#%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

https://zenn.dev/fussy_tech/articles/aec7707eade4c5#%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E5%88%B6%E9%99%90%E3%82%92%E5%9B%9E%E9%81%BF%E3%81%97%E3%81%AA%E3%81%8C%E3%82%89-google-fonts-%E3%82%92%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80


### 動的に表示させるためにクエリパラメータを使用

次にドキュメントにも書いてあることですが、動的にタイトルや説明文、日付、テンプレートを出力するために **`https://example.com/api/og?title=hoge` のような形でクエリパラメータを使用しました。**

今回だと `https://semipos.vercel.app/api/og?title=...` のような形で、**`?` 以下にユーザーが入力したデータを埋め込むようにしており、Edge Function 側でそれらを取り出し、Image Responseに埋め込むという構成になっています。**


```tsx: src/app/og/route.tsx
/**
 * あくまでイメージです（実際のコードとは違います）
 * @see https://vercel.com/docs/functions/edge-functions/og-image-generation/og-image-examples#dynamic-text-generated-as-image
 */

import { ImageResponse } from 'next/og';
// App router includes @vercel/og.
// No need to install it.
 
export const runtime = 'edge';
 
export async function GET(request: Request) {
  try {
    const { searchParams } = new URL(request.url);
 
    // ?title=<title>
    const hasTitle = searchParams.has('title');
    const title = hasTitle
      ? searchParams.get('title')?.slice(0, 100)
      : 'My default title';
 
    return new ImageResponse(
      (
        <div
          style={{
            backgroundColor: 'black',
            backgroundSize: '150px 150px',
            height: '100%',
            width: '100%',
            display: 'flex',
            textAlign: 'center',
            alignItems: 'center',
            justifyContent: 'center',
            flexDirection: 'column',
            flexWrap: 'nowrap',
          }}
        >
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              justifyItems: 'center',
            }}
          >
            <img
              alt="Vercel"
              height={200}
              src="data:image/svg+xml,%3Csvg width='116' height='100' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M57.5 0L115 100H0L57.5 0z' /%3E%3C/svg%3E"
              style={{ margin: '0 30px' }}
              width={232}
            />
          </div>
          <div
            style={{
              fontSize: 60,
              fontStyle: 'normal',
              letterSpacing: '-0.025em',
              color: 'white',
              marginTop: 30,
              padding: '0 120px',
              lineHeight: 1.4,
              whiteSpace: 'pre-wrap',
            }}
          >
            {title}
          </div>
        </div>
      ),
      {
        width: 1200,
        height: 630,
      },
    );
  } catch (e: any) {
    console.log(`${e.message}`);
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
}
```

### `generateMeatadata` で クエリパラメータ を受け取る方法

最後に`@vercel/og` とはあまり関係ないのですが、App Router における動的 OGP の設定法について触れておきます。

Next.js 13.4 以降では Metadata API を用いることで動的なOGPを生成することが可能です。

https://nextjs.org/docs/app/api-reference/functions/generate-metadata

ただ **`generateMetadata` 関数内で、クエリパラメータ = `searchParams` を受け取りたい場合には `page.tsx` でこれらの設定をする必要があるということは頭に入れておいた方が良いです。**
（＝ `layout` ではできない）

今回は X（旧Twitter） 上で OGP を出せるようにするため、シェアボタンを押した際にユーザーの入力情報はすべてトップページのクエリパラメータに含める形で URL を出力しています。

<br />

<img src="https://i.gyazo.com/5ad165c6b8b336307aec531bce0f3c57.gif" width=78% />

<br />

そのため **`generateMetadata` 関数内で searchParams を扱える必要があり、以下のようにトップの `page.tsx`内で諸々の設定しました。**

```tsx:src/app/page.tsx
// Topページの OGP を設定
export function generateMetadata({ params, searchParams }: Props): Metadata {

  // ?title=<title>
  const title = searchParams?.title
    ? searchParams.title
    : 'Seminar Post Generator'

  ...

  return {
    metadataBase: new URL('https://semipos.vercel.app/'),
    title: 'Seminar Post Generator',
    description: 'セミナー登壇者っぽく、個人的な予定を告知できるアプリ',
    icons: [{rel: 'icon', url: Favicon.src}],
    openGraph: {
      title: 'Seminar Post Generator',
      description: 'セミナー登壇者っぽく、個人的な予定を告知できるアプリ',
      images: [
        `${baseURL}/og?title=${title}&description=${description}&date=${date}&startTime=${startTime}&endTime=${endTime}&genre=${genre}&templateId=${templateId}`,
      ],
    },
    twitter: {
      title: 'Seminar Post Generator',
      description: 'セミナー登壇者っぽく、個人的な予定を告知できるアプリ',
      card: 'summary_large_image',
      images: [
        `${baseURL}/og?title=${title}&description=${description}&date=${date}&startTime=${startTime}&endTime=${endTime}&genre=${genre}&templateId=${templateId}`,
      ],
    },
  }
}
```

こうすることで、X(旧Twitter)上でURLをシェアした際に個々人が入力した値に沿ったアイキャッチ画像が出力されるようになっています。

# おわりに

今回久々に個人開発をしたのですが、改めて**自分が考えたものを１から実装するのは楽しいということを実感しました。** また、ある程度期限も決められた中での開発だったので、メリハリがついていい感じに実装できた気がしています。

最後まで読んでいただきありがとうございました、もし面白そうと感じたらサービスを使っていただけるとありがたいです。

※**ちなみに、このサービスは告知する予定がどーでもよければよいほど面白いのかなと感じています。**

▼サービス URL
https://semipos.vercel.app/

<br />

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/751055/fee1392d-0304-30ea-c663-c594811a0677.png)



# 参考資料など
https://vercel.com/docs/functions/edge-functions/og-image-generation

https://zenn.dev/hiromu617/articles/c03fef6f4d6c6e

https://cruip.com/generate-dynamic-open-graph-and-twitter-images-in-next-js/

https://tech.commmune.jp/entry/2023/01/24/113000

https://zenn.dev/temasaguru/articles/2968736b5a2f41

https://app.haikei.app/

https://note.com/sanjohitoma/n/nfccb194067a5