microCMS + Next.js でお問い合わせフォームを作成する
microCMSのコンテンツを取得すると以下のようになる。
オブジェクト client.getObject
{
"createdAt": "2024-07-29T05:44:35.482Z",
"updatedAt": "2024-07-30T02:33:15.635Z",
"publishedAt": "2024-07-29T05:52:39.625Z",
"revisedAt": "2024-07-30T02:33:15.635Z",
"text_field": "テキストフィールド",
"text_area": "テキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア",
"rich_text": "<h1 id=\"h6aad7d63ec\">見出し1</h1><h2 id=\"had7ce3f1cb\">見出し2</h2><h3 id=\"h594976e74f\">見出し3</h3><h4 id=\"h0f52d4725a\">見出し4</h4><h5 id=\"h169558c650\">見出し5</h5><p><code>console.log('hello, world')</code></p><p>左揃え</p><p style=\"text-align: center\">中央揃え</p><p style=\"text-align: right\">右揃え</p><hr><blockquote><p>引用引用引用引用引用引用引用引用引用引用引用</p></blockquote><div data-filename=\"test.js\"><pre><code class=\"language-javascript\">console.debug('debug')\nconsole.log('log')\nconsole.info('info')\nconsole.warn('warn')\nconsole.error('error')</code></pre></div><table><tbody><tr><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー1</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー2</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー3</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー4</p></th></tr><tr><td colspan=\"1\" rowspan=\"1\"><p>内容A</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容B</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容C</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容D</p></td></tr></tbody></table><ul><li>リスト1</li><li>リスト2</li><li>リスト3</li></ul><ol><li>リスト1</li><li>リスト2</li><li>リスト3</li></ol><p><a href=\"http://localhost:3000\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">リンク</a></p><figure><img src=\"https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg\" alt=\"\" width=\"1104\" height=\"1104\"></figure><div class=\"iframely-embed\"><div class=\"iframely-responsive\" style=\"height: 140px; padding-bottom: 0;\"><a href=\"https://github.com\" data-iframely-url=\"//cdn.iframe.ly/api/iframe?card=small&url=https%3A%2F%2Fgithub.com%2F&key=c271a3ec77ff4aa44d5948170dd74161\"></a></div></div><script async src=\"//cdn.iframe.ly/embed.js\" charset=\"utf-8\"></script>",
"image": {
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
"height": 1104,
"width": 1104
},
"images": [
{
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
"height": 1104,
"width": 1104
},
{
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg",
"height": 1104,
"width": 1104
}
],
"date": "2024-07-30T03:00:00.000Z",
"boolean": true,
"select_single": [
"セレクトA"
],
"select_multi": [
"セレクトD",
"セレクトE"
],
"number": 1200
}
リストclient.getList
{
"contents": [
{
"id": "yvbocg_e-p",
"createdAt": "2024-07-30T02:35:26.517Z",
"updatedAt": "2024-07-30T02:35:26.517Z",
"publishedAt": "2024-07-30T02:35:26.517Z",
"revisedAt": "2024-07-30T02:35:26.517Z",
"test_object": {
"createdAt": "2024-07-29T05:44:35.482Z",
"updatedAt": "2024-07-30T02:33:15.635Z",
"publishedAt": "2024-07-29T05:52:39.625Z",
"revisedAt": "2024-07-30T02:33:15.635Z",
"text_field": "テキストフィールド",
"text_area": "テキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア",
"rich_text": "<h1 id=\"h6aad7d63ec\">見出し1</h1><h2 id=\"had7ce3f1cb\">見出し2</h2><h3 id=\"h594976e74f\">見出し3</h3><h4 id=\"h0f52d4725a\">見出し4</h4><h5 id=\"h169558c650\">見出し5</h5><p><code>console.log('hello, world')</code></p><p>左揃え</p><p style=\"text-align: center\">中央揃え</p><p style=\"text-align: right\">右揃え</p><hr><blockquote><p>引用引用引用引用引用引用引用引用引用引用引用</p></blockquote><div data-filename=\"test.js\"><pre><code class=\"language-javascript\">console.debug('debug')\nconsole.log('log')\nconsole.info('info')\nconsole.warn('warn')\nconsole.error('error')</code></pre></div><table><tbody><tr><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー1</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー2</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー3</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー4</p></th></tr><tr><td colspan=\"1\" rowspan=\"1\"><p>内容A</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容B</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容C</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容D</p></td></tr></tbody></table><ul><li>リスト1</li><li>リスト2</li><li>リスト3</li></ul><ol><li>リスト1</li><li>リスト2</li><li>リスト3</li></ol><p><a href=\"http://localhost:3000\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">リンク</a></p><figure><img src=\"https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg\" alt=\"\" width=\"1104\" height=\"1104\"></figure><div class=\"iframely-embed\"><div class=\"iframely-responsive\" style=\"height: 140px; padding-bottom: 0;\"><a href=\"https://github.com\" data-iframely-url=\"//cdn.iframe.ly/api/iframe?card=small&url=https%3A%2F%2Fgithub.com%2F&key=c271a3ec77ff4aa44d5948170dd74161\"></a></div></div><script async src=\"//cdn.iframe.ly/embed.js\" charset=\"utf-8\"></script>",
"image": {
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
"height": 1104,
"width": 1104
},
"images": [
{
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
"height": 1104,
"width": 1104
},
{
"url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg",
"height": 1104,
"width": 1104
}
],
"date": "2024-07-30T03:00:00.000Z",
"boolean": true,
"select_single": [
"セレクトA"
],
"select_multi": [
"セレクトD",
"セレクトE"
],
"number": 1200
},
"text": "テスト"
}
],
"totalCount": 1,
"offset": 0,
"limit": 10
}
もしAPIキー一つで運用する場合は、サーバー経由でアクセスするべきということね。
お問い合わせなどの更新がされるコンテンツAPIのみPOSTなどの書き込み系の個別権限を設定する。このAPIキーはサーバーサイドで処理するなど取り扱いには注意をする。
APIキーを増やす理由は端末毎に利用するなどの特殊な理由なのだろう。
基本的には、1つのAPIキーで個別に権限を設定する形になる気がする。
そうなると、POSTが1つでもある場合、サーバーサイドで利用することになることは間違いない。
マルチデバイスに展開をしている場合、「ウェブサイト用」「iOS用」「Android用」のように同じ権限でAPIキーを複数作成することができます。
リスト形式のAPIのみ利用可能です。オブジェクト形式のAPIでは利用できませんので、ご注意ください。
クエリパラメータを指定しない場合は、ステータスは公開中でコンテンツが作成されます。
WRITE APIのクエリはTeamプラン、Businessプラン、Advancedプラン、Enterpriseプランでご利用いただける機能です。
プランごとに利用できる機能については、料金プランページをご覧ください。
consent: z.boolean().refine((val) => val === true, {
message: "Please read and accept the terms and conditions",
})
shadcn ui のForm
コンポーネントでサーバーアクション使用したかったけど、react-hook-form
がサーバーアクションに対応していないっぽい。で、issueでもshadcn uiを使わずに、標準のform
で実装するように書いてあるので、サーバーアクションを利用したい場合は、shadcn uiのForm
は利用できない。
shadcnによるフォーム・コンポーネントは使用せず、ネイティブのものを使用してください
UIは自前で用意するとして、バロデーションライブラリを探したところ、conform
というものがあるらしいので、今度すこし触ってみようと思った。Zodと一緒に利用できるので良さげかもしれない。と個人的には思っている。(まだ使ったことないけど)
参考サイトを基にGoogle ReCAPTCHAのトークンを検証する処理を実装した。
けど、ヘッダーの情報がドキュメントに記述されていないので、Content-Typeをapplication/json
で送っていたらinvalid-input-response
で怒られていた。そのため、Content-Typeはapplication/x-www-form-urlencoded
にする必要があった。
type SiteverifyResponse = {
success: boolean
challengeTs?: Date // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
hostname?: string // the hostname of the site where the reCAPTCHA was solved
score: number
action: string
errorCodes?: unknown[] // optional
}
const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY!
const GOODLE_BASE_URL = process.env.GOODLE_BASE_URL || 'https://www.google.com'
export async function siteverify(token: string): Promise<SiteverifyResponse> {
const response = await fetch(`${GOODLE_BASE_URL}/recaptcha/api/siteverify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
secret: RECAPTCHA_SECRET_KEY,
response: token,
}),
})
const json = await response.json()
return {
success: json['success'],
challengeTs: new Date(json['challenge_ts']),
hostname: json['hostname'],
score: json['score'],
action: json['action'],
errorCodes: json['error-codes'],
}
}
application/x-www-form-urlencoded: キーと値は、 '&' で区切られ、キーと値の組が '=' で結ばれた形でエンコードされます。キーや値が英数字以外の文字であった場合は、パーセントエンコーディングされます。このため、このタイプはバイナリデータを扱うのには向きません(代わりに multipart/form-data を使用してください)
import type { NextRequest } from 'next/server'
import type { Contact } from '@/libs/data/contact'
import { toNewContents } from '@/libs/microcms/webhook'
import { sendMail } from '@/libs/sendgrid/mail'
import { createHmac, timingSafeEqual } from 'crypto'
import { NextResponse } from 'next/server'
const secret = process.env.MICROCMS_WEBHOOK_SECRET || 'secret'
export async function POST(request: NextRequest): Promise<NextResponse> {
const signature = request.headers.get('X-MICROCMS-Signature') || request.headers.get('x-microcms-signature') || ''
const body = await request.json()
const expectedSignature = createHmac('sha256', secret)
.update(typeof body === 'object' ? JSON.stringify(body) : body)
.digest('hex')
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
const contact = toNewContents<Contact>(body as never).publishValue
await sendMail({
to: contact.email,
from: 'test@local.co.jp',
subject: 'お問い合わせいただきありがとうございます!',
text: `${contact.name}様 お問い合わせいただきありがとうございます!`,
})
return NextResponse.json({ status: 200 })
}
シークレット値が設定されている場合のみ、リクエストのヘッダにX-MICROCMS-Signature: <COMPUTED_HASH>が付与されます。
※ペイロードはmicroCMSがシークレット値とリクエストボディからSHA-256を使用したハッシュベースのHMACで生成します。
リクエストヘッダ「X-MICROCMS-Signature」は、Webhookの種類によっては、「x-microcms-signature(全て小文字)」の表記で送信される場合がございます。
本ヘッダの処理にあたっては、大文字・小文字を区別しない形での実装をお願い申し上げます。
"source.fixAll.eslint": "explicit"
を追加することで、保存時にclassNameがソートされる。
"settings": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
},
},
callees: ["cn"]
を追加することで、cn関数内のclassNameもソート対象にすることができる。
settings: {
tailwindcss: {
callees: ["cn"],
},
},