@200stack/static-forms-next
v0.2.4
Published
Next.js Static Forms adapter for 200stack build-time materialization.
Readme
Static Forms Next adapter
checkboxes(複数選択)は 0.2.3 以上(core は 0.2.2 以上)・build protocol v2 が必要です。
電話番号 tel・URL url・日付 date・数値 number は 0.2.4 以上(core も 0.2.4 以上)・build protocol v2 が必要です。
App Router の StaticForm と、Pages Router の getStaticFormProps / StaticFormFromProps でフォームを表示します。本番は output: 'export' とビルド用の manifest・snapshot が必要です。
next.config.mjs には静的出力と adapter を設定してください。
// next.config.mjs
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
adapterPath: require.resolve('@200stack/static-forms-next/adapter'),
}
export default nextConfigv2 と開発表示は slotId だけで描画できます。slotId は、200stack のフォームの画面の「サイトに貼るコード」に入っている名前(コードに書く名前)です。
// App Router
import { StaticForm } from '@200stack/static-forms-next'
export default async function ContactPage() {
return <main>{await StaticForm({ slotId: 'slot.contact' })}</main>
}// Pages Router
import type { InferGetStaticPropsType } from 'next'
import { StaticFormFromProps } from '@200stack/static-forms-next/react'
export async function getStaticProps() {
const { getStaticFormProps } = await import('@200stack/static-forms-next')
return { props: await getStaticFormProps({ slotId: 'slot.contact' }) }
}
export default function ContactPage(props: InferGetStaticPropsType<typeof getStaticProps>) {
return <StaticFormFromProps {...props} />
}v2 の getStaticFormProps は { staticForm: { buildProtocolVersion: 2, html, semanticDomDigest, instanceId, publicFormId, publicApiOrigin, configVersion, configDigest, publicIdGeneration, definitionDigest, hydrationOwner } } を返します。html はサーバーで生成した必須の文字列で、renderTree・style・lang は props に含みません。App Router の StaticForm も同じ props を client component に渡します。ブラウザでは受け取った HTML に controller を接続します。
これは 0.2.0 で予定する公開 API の破壊的変更です。v2 の props を手作り・保存・加工している場合は、同じ版の getStaticFormProps で作り直してください。StaticFormRenderProps は buildProtocolVersion で分かれる union で、v2 の側は StaticFormV2RenderProps として import できます。App Router では、html は SSR の HTML に加えて RSC の payload にも含まれます(以前の renderTree の代わり)。v1 (buildProtocolVersion が未指定または 1) は従来の renderTree と React 描画を維持し、開発表示は { mode: 'development', html } のままです。Pages Router の表示用 StaticFormFromProps は引き続き /react から import してください。
同じフォーム(コードに書く名前が同じもの)を1ページに複数置く場合は、2つ目以降に index: 1、index: 2 のように異なる値を渡してください。既定は 0 です。同じ instanceId の2つ目以降は診断を表示し、送信の controller を起動しません。生成される ID は固定の CSS セレクタとして扱わないでください。
v2 と開発表示の言語はフォームの定義の lang (ja・en) に従います。言語ごとに別のフォームを作り、それぞれのページに置いてください。App Router の StaticForm と Pages Router の getStaticFormProps は通常 lang の指定が不要です。定義に lang がないフォームだけ、adapter の lang: 'en' などで既定の文言を選べます。どちらも省略なら日本語です。定義と異なる lang を指定すると、本番ビルド・開発表示ともエラーになります。同じ言語の明示指定は省略と同じ結果です。
送信・戻る・確認の送信・noscript・状態・項目エラーと開発の案内は、解決した言語の辞書を使います。保存された項目名や submitLabel・backLabel などの文言は自動翻訳しません。backLabel は確認の mode: 'configured' で省略可能です。本番・開発のフォームルートには HTML の lang と既存の data-200stack-static-form-lang を出します。ページ全体の <html lang> はサイト側で指定してください。データ取得に失敗して定義が分からない開発診断は、adapter の lang、省略なら日本語を使います。v1 の出力は従来の英語固定で、定義・adapter の lang を適用しません。StaticFormLang 型を adapter から import できます。
0.1.0 は 0.2.0 のビルド入力を読めません。新しい定義・入力を使うサイトは adapter と core・SDK を対応する版へ更新してください。
言語を足す手順
- core の
src/core/messages.tsにフォーム文言・状態・検証エラー・開発診断をすべて持つ辞書を足し、staticFormLanguageNamesに表示名を足します。StaticFormLangと wire schema の許可値は辞書のキーから導かれます。実行時の登録はしません。 - 管理画面の自動返信の固定文(件名・本文・HTML の
langとフォント)と、作成時の既定定義・言語選択を足します。 - core の dist と controller bundle、Worker の同梱 controllers、adapter の controller contract、agent の生成物を作り直し、browser import 境界・bundle 予算・core と adapters のテストを確認します。言語追加には controller の再配備も必要です。
- DB(管理画面) → Worker(新しい core と controller) → build-runtime → 管理画面 → npm の順で配備し、サイトを再ビルドします。
開発表示の重複診断には CSS の :has() を使い、JS は実行しません。:has() と埋め込みの <style> が使えるブラウザ・CSP が必要です。
route・outputPath は v1 入力でのみ必須です。v2 では使いませんが、渡された値は検証します。
見た目を指定する(v2)
import { type StaticFormStyle } from '@200stack/static-forms-next'
const style = {
root: 'mx-auto max-w-xl',
section: { title: 'text-xl font-bold' },
field: { root: 'mb-5', label: 'font-medium', error: 'text-rose-600' },
email: { control: 'rounded border aria-[invalid=true]:border-rose-600' },
radio: { group: 'grid gap-2', option: 'flex gap-2', input: 'size-4' },
byName: { email: { label: 'text-indigo-800' } },
} satisfies StaticFormStyle値はクラス名の文字列だけです。項目は field → 種類 (text / email / textarea / select / radio / checkbox) → byName[name] の順で連結します。この順は CSS の優先順位を保証しません。byName の未知の name は警告になり、項目種類に合わない部品も警告して使いません。
style と lang はサーバーで HTML に反映し、v2 の client props には含めません。remount の key は HTML の SHA-256 である semanticDomDigest です。HTML が変わると remount して入力・フォーカス・送信状態をリセットし、同じ digest の再描画では保持します。エラーの入力は aria-invalid="true"、項目外枠は data-200stack-static-form-invalid を目印にできます。送信中は root に aria-busy="true" が付き、送信・確認ボタンは disabled になります。
| キー | DOM (part) |
| --- | --- |
| root / form | ルートの section / 内側の form (root / form) |
| section.root / .title / .description | section / h2 / p |
| field.root、*.label / .description / .error | 項目の div、label または legend、説明 p、エラー p |
| text・email・textarea・select の .control | input / textarea / select |
| radio・checkbox の .group / .option / .input | fieldset / 選択肢の label / input |
| formErrors / status / submit | 全体エラー / 状態 / 主送信 button |
| privacy.root / .link | 案内 p / a |
| confirmation.root / .title / .description / .list / .item / .label / .value / .back / .send | 確認画面の div / h2 / p / dl / div / dt / dd / buttons |
| completion.root / .title / .body | 完了画面の div / h2 / p |
完全な公開対応は次のとおりです。
| style のキー | part の正確な値 | 本番の DOM | 開発の表示 |
| --- | --- | --- | --- |
| root / form | root / form | section / form | 外側の div[role=form] (root form) |
| section.* | section / section-title / section-description | section / h2 / p | 同じ |
| field.root / *.label / .description / .error | field / label / description / error | 項目 div / label または legend / p / p | 同じ |
| text 系 .control | control | input / textarea / select | 同じ |
| choice .group / .option / .input | group / option / input | fieldset / label / input | 同じ |
| formErrors / status / submit | form-errors / status / submit | div / div / button | 出ない / 出ない / 無効の button |
| privacy.* | privacy / privacy-link | p / a | 同じ |
| confirmation.* | confirmation、confirmation-title、confirmation-description、confirmation-list、confirmation-item、confirmation-label、confirmation-value、confirmation-back、confirmation-send | div / h2 / p / dl / div / dt / dd / buttons | 出ない |
| completion.* | completion / completion-title / completion-body | div / h2 / p | 出ない |
条件で DOM が出ない説明・確認・完了などは、対応する指定を書いても効果がありません。consentText が無い checkbox には group と label がありません。theme と公開目印は v2 と開発表示だけで、v1 の出力には効きません。checkbox は同意文があれば fieldset > legend + label > input、select は先頭に空の選択肢を出します。確認画面は configured・generated とも入力内容を表示します。条件では未選択 select の field_not_equals が真になるため、選択済みだけを求める場合は and(field_present, field_not_equals) を使います。制御文字、JSON 全体 32 KiB、合成済み class 属性 2,000 バイトを超える指定はビルドエラーです。開発表示も同じ検査・クラス指定を使います。
Tailwind は完全なクラス名をファイルに書き、v3 は content、v4 は @source にそのファイルを含めます。動的な bg-${color}-500 は検出されません。エラー用には aria-[invalid=true]:border-rose-600 のように書きます。
CSS の目印
v2 本番と開発表示では、style の各部品に data-200stack-static-form-part が付きます。複数の役割を持つ外側の開発表示では root form のように空白区切りなので、[data-200stack-static-form-part~="form"] と ~= を使います。
| 公開の目印 | 場所・値 |
| --- | --- |
| data-200stack-static-form-part | 表の部品名 |
| data-200stack-static-form-type | 項目の外枠、種類 |
| data-200stack-static-form-name | 項目の外枠、name |
| data-200stack-static-form-required / -invalid | 必須 / エラーの項目外枠 |
name には .・: が入り得るため、[data-200stack-static-form-name="customer.email"] のように必ず引用符で囲みます。管理画面で name を変えると name を狙う CSS と byName は黙って外れます。既存の controller 用属性、controller 実行時の要素、開発表示の説明文は公開の約束ではありません。追加量の目安は項目ごとに 100〜200 バイト、選択肢ごとに約 70 バイトです(Next では HTML が client props にも載ります)。
theme.css と CSP
app/layout.tsx または pages/_app.tsx で import '@200stack/static-forms-next/theme.css' を読み込みます。色・角丸は --200stack-sf-accent、--200stack-sf-accent-hover、--200stack-sf-text、--200stack-sf-muted、--200stack-sf-border、--200stack-sf-frame-border、--200stack-sf-error、--200stack-sf-error-text、--200stack-sf-radius、--200stack-sf-surface、--200stack-sf-error-surface、--200stack-sf-on-accent、--200stack-sf-completion-surface、--200stack-sf-completion-text をルート目印で上書きできます。
@layer static-forms;
[data-200stack-static-form-part~="root"] { --200stack-sf-accent: #0f766e; --200stack-sf-surface: #06251f; --200stack-sf-text: #ecfdf5; --200stack-sf-border: #2d6a5c; }一般のサイトでは CSS の先頭で @layer static-forms; を宣言します。レイヤー外の CSS と、後ろにあるレイヤーの CSS が勝ちます。Tailwind v4 では @layer theme, base, static-forms, components, utilities; を @import "tailwindcss"; より前に置き、@import '@200stack/static-forms-next/theme.css'; と layer() なしで読み込みます。
theme.css だけの本番は外部 CSS なので style-src 'self'(または配信元)で足りますが、v2 は常に固定の <style> も出します。本番には 'sha256-LE3mSYpIJxrT67iqOfLXzAcqron3YCrRA+rogsSVPYA='、開発表示には 'sha256-PooA9aoFhckRsEL2CK2Uxp0tODRwW2JydE6jQ+WgQ+M=' を style-src(指定時は style-src-elem にも)へ追加してください。開発表示の重複診断 style は instanceId ごとに変わるため、その出力の hash も必要です。クラス指定を使う本番でも固定 style の hash は必要です。
0.1 からの移行
| 0.1 | 0.2 |
| --- | --- |
| 平たい label / description / error | field.label / .description / .error |
| 平たい input / textarea / select | text / email / textarea / select の .control |
| fields.* / types.* の input | text 系は .control、radio・checkbox は .input |
| 文字列の field / section / confirmation / completion | それぞれの .root |
| choiceGroup / choiceLabel / radio / checkbox | radio / checkbox の .group / .option / .input |
| sectionTitle / sectionDescription | section.title / .description |
| confirmationList など | confirmation.list など |
| types / fields | 種類キー / byName |
| first / last | section 内の表示位置は CSS、特定項目は byName |
削除済みの StaticFormElementClasses・StaticFormFieldClasses の代わりに StaticFormFieldStyle を使います。旧キーは読み替えず、たとえば Static Forms style uses 0.1 key at fields.email; see README: Migrating from 0.1 としてエラーにします。
送信の完了と計測
完了時はメッセージを表示するか、サイトのルートからのパス(例 /contact/thanks?from=contact)へ移動します。移動先は 2,048 字以内で、/ から始まる必要があります。//、絶対 URL、バックスラッシュ、空白・制御文字(0x00〜0x20、0x7f〜0x9f、U+2028・U+2029)、#、パスの .. 区間、エンコードされた .・/・バックスラッシュ(%2e・%2f・%5c、大文字も含む)は拒否します。クエリは使えます。移動先はページの URL を基準に解決し、同じ origin であることを確認します。
サンクスページはサイト側で用意してください。末尾のスラッシュはサイトのルーティングに合わせ、検索に載せたくない場合は noindex を設定してください。そのページの計測タグは、ページを直接開いた場合にも動きます。移動中は「移動しています…」(en: Redirecting…)を表示し、送信ボタンは無効のままです。移動の呼び出しが失敗した場合は、送信失敗とは区別した案内と手動リンクを表示します。location.assign を使うため、戻るボタンで入力済みの完了状態に戻れます(bfcache で保持された場合)。
受付が確認できた時点で、ルートから 200stack-static-form:accepted を通知します。bubbles: true・cancelable: false の CustomEvent です。message では完了要素を表示した後、redirect では移動を呼ぶ前に通知します。直接受付とポーリングの両方が対象です。確認できずにポーリングが時間切れになった場合や dispose 後には通知しません。
detail は通知ごとに作る凍結した { slotId?: string, publicFormId: string, instanceId: string } です。入力値・受付 ID・receipt・session は含みません。v2 の HTML には data-200stack-static-form-slot があり、SDK が渡した slotId を通知できます。属性がない旧い HTML では slotId のキーを省略します。v1 と開発表示にはこの属性を出しません。回数の保証は「同じ controller の同じ送信で最大 1 回」です。再読み込みや remount をまたぐ 1 回だけの保証はありません。イベントは偽造できるので、課金・権限付与などの受付証明には使えません。
STATIC_FORM_ACCEPTED_EVENT と StaticFormAcceptedEventDetail は core と adapter から export します。Next のブラウザ用コードでは @200stack/static-forms-next/react から import してください。リスナーの Promise は待たず、通常の DOM リスナーの例外はブラウザが報告します。通知をキャンセルして完了処理を止めることはできません。
GTM と GA4 の例は どちらか一方 を使ってください。リスナーはサイト全体で 1 個とし、必要なら slotId で対象を絞ります。redirect で同じ成果をイベントとサンクスページの両方で数えないでください。移動前の通知は計測先への配送完了を保証しません。
計測は、サイトの Cookie の同意の管理(GTM・GA4 の同意モードなど)に従って送ってください。EU などでは計測に同意が要るのが一般的です。例は入力値を送りません。入力値を計測に足さないでください。
共有の client component を App Router の root layout または Pages Router の _app に一度置きます。次は GTM の例です。
// components/StaticFormAnalytics.tsx
'use client'
import { useEffect } from 'react'
import { STATIC_FORM_ACCEPTED_EVENT, type StaticFormAcceptedEventDetail } from '@200stack/static-forms-next/react'
type AnalyticsWindow = Window & {
dataLayer?: Array<Record<string, unknown>>
gtag?: (...args: unknown[]) => void
}
export default function StaticFormAnalytics() {
useEffect(() => {
const analytics = window as AnalyticsWindow
const onAccepted = (event: Event) => {
const { slotId } = (event as CustomEvent<StaticFormAcceptedEventDetail>).detail
if (slotId !== 'slot.contact') return
analytics.dataLayer = analytics.dataLayer || []
analytics.dataLayer.push({ event: 'static_form_accepted', form_slot: slotId })
}
document.addEventListener(STATIC_FORM_ACCEPTED_EVENT, onAccepted)
return () => document.removeEventListener(STATIC_FORM_ACCEPTED_EVENT, onAccepted)
}, [])
return null
}GTM ではカスタムイベント static_form_accepted をトリガーにし、GA4 イベントタグの名前を generate_lead、パラメーター form_slot を同名のデータレイヤー変数に設定します。GA4 を直接呼ぶ場合は、上の dataLayer の 2 行を次に置き換えてください(リスナーは追加しません)。
if (typeof analytics.gtag === 'function') {
analytics.gtag('event', 'generate_lead', { form_slot: slotId })
}CSP では共有 JS の配信元を script-src で許可します。インラインの計測タグを置く場合は hash または CSP と一致する nonce が必要です。GTM・GA 自体の配信元の script-src と送信先の connect-src なども別途許可してください。
開発中の表示
アプリの設定の「開発用トークン」でトークンを発行してください。発行・利用できるのは admin と developer です。アプリを実行するサーバー側の環境に、次を設定します。値は、開発用トークンの画面に出る環境変数の例に置き換えてください。STATIC_FORMS_DEV_ENVIRONMENT_ID には、アプリのデフォルトブランチの環境の ID が自動で入っているので、そのまま使ってください。表示されるのは、そのアプリに接続したフォームです(環境・ブランチごとには分かれません)。ローカルの画面に「環境・branch に対応する設置がありません。」と出たら、開発用トークンの画面で今の環境 ID を確かめ、STATIC_FORMS_DEV_ENVIRONMENT_ID を置き換えてください。
export STATIC_FORMS_DEV_ENABLED=1
export STATIC_FORMS_DEV_API_URL=https://dashboard.example.com
export STATIC_FORMS_DEV_TOKEN=sfdev_REPLACE_WITH_ISSUED_TOKEN
export STATIC_FORMS_DEV_APP_ID=app-id
export STATIC_FORMS_DEV_ENVIRONMENT_ID=environment-id
export STATIC_FORMS_DEV_BRANCH=feature/contactAPI URL は HTTPS の origin を指定します。STATIC_FORMS_DEV_BRANCH はフォームの選び分けには使いません。作業中のブランチ名を入れておくと、保存データが別のブランチで取ったものでないかを確かめられます。branch を付けずに取った開発用のデータ(scope の branch が *)を使うときは * を入れてください。git からは推測しません。トークンを NEXT_PUBLIC_*・PUBLIC_* に入れないでください。
next dev では、公開版の入力を開発用プレビューとして表示します。送信はできず、クライアントの送信 controller も起動しません。本番のビルドや実行種別が不明の場合は開発 API を呼びません。ビルド用の入力・pin・STATIC_FORMS_BUILD_ONLY などがあれば、開発サーバーでも本番の入力を使います。入力が無ければエラーになります。
API のデータを保存して使う場合は、プロジェクトのルートで次を実行します。
npx static-forms pull
export STATIC_FORMS_DEV_SNAPSHOT_PATH=.static-forms/development.json既定の保存先は配信対象外の .static-forms/development.json です。既存ファイルを更新する場合は npx static-forms pull --force を使います。保存データにトークンは含まれません。
保存した snapshot が指定されていれば API より優先します。読み込みに失敗しても API に切り替えず、フォームの位置に診断を表示します。保存データを読む場合も、開発の有効化とアプリ・環境・STATIC_FORMS_DEV_BRANCH の設定は必要です。
