# 自分のサイトで NPS アンケートを実施する：メールのリンクからスコアまで

> メールに 0〜10 のスコアを載せ、スコアに応じて変わる追加質問を自分のページで尋ね、スクリプトで NPS を計算する NPS アンケートを実施します。

公開日 2026-10-25 · https://ikiform.com/ja/guides/feedback-form/nps-survey

アンケートメールに 0〜10 の 11 個のリンクを載せ、それぞれ自分のサイトのページを、スコアと不透明な回答者トークンを URL に付けて指すようにします。ページはスコアをあらかじめ選択し、スコアに応じた追加質問を尋ね、本人が送信を押すまで何も記録しません。スコアは 0〜10 の数値フィールドに保存し、NPS は 9 と 10 の割合から 0〜6 の割合を引いて計算します。

## NPS はどこが違うのか

NPS は、固定のスケールで 1 つだけ質問します。私たちを薦める可能性はどのくらいか、0〜10 で。次の 3 点が、ピラーページの 1〜5 の評価と異なります。

- **スケールは 0 から始まります。** Ikiform の `rating` フィールドは 1 から最大 10 までなので、0 を保持できません。NPS では `min: 0`、`max: 10`、`integer: true` の `number` フィールドを使い、数値として保存します。
- **たいていはメールで送ります。** メールクライアントはフォームの送信やスクリプトの実行を確実には行えないので、メールにはリンクを載せ、フォームは自分のサイトに置きます。Ikiform にはホストされたフォームページがありません。
- **メールのリンクは機械にクリックされます。** 多くのメールセキュリティスキャナーは、メッセージ内のリンクをすべて開いて確認します。リンクを開いただけでスコアが記録されると、スキャナーからのスコアが集まってしまいます。ページは本人が送信を押したときにだけ記録します。

図: NPS のスコア帯ごとに追加質問が変わり、結果に反映される仕組み

1. 0〜6 のスコアでは何に失望したかを尋ね、批判者として数えます。
2. 7 か 8 では何があれば 10 になるかを尋ね、合計にだけ数えます。
3. 9 か 10 では最も価値を感じている点を尋ね、推奨者として数えます。
4. NPS は推奨者の割合から批判者の割合を引いたもので、−100 から 100 の値になります。

役に立つ答えがスコアによって変わるので、質問もスコアに合わせて変えます。批判者の理由は直すべき問題で、推奨者の理由はホームページに使える言葉です。

## フィールド

フォーム: NPS survey

- **友人や同僚に私たちを薦める可能性はどのくらいありますか？** (`score`, number, 必須)
- **そのスコアの主な理由は何ですか？** (`reason`, textarea)
- **回答者** (`respondent`, text)
- **アンケートの回** (`wave`, text, 必須)

スコアに 11 や 7.5 を試してみてください。Ikiform はどちらも拒否します。`wave` がない場合も拒否されるので、すべての回答がいずれかのアンケートの回に属します。

| フィールド | 理由 |
| --- | --- |
| `score` | `number`、0〜10、整数のみ。数値として保存 |
| `reason` | 追加質問。スコアだけでも数えられるので任意 |
| `respondent` | 人ごとに生成して送る不透明なトークン。自分のデータベースで顧客に結び付けます |
| `wave` | `2026-q4` のような回。日付で絞り込まずに四半期を比較できます |

顧客のメールアドレスやアカウント ID をリンクに入れないでください。URL はブラウザーの履歴、プロキシのログ、転送されたメールに残ります。16 文字以上のランダムなトークンなら、誰が回答したかは自分にだけわかり、他の誰にも何も伝わりません。

## アンケートメール

リンクの並びはメールツールの差し込みタグで作るか、メールを送るコードで生成します。この関数は回答者 1 人分の HTML を返します。

`nps-email.js`:

```js
function npsRow(respondent, wave) {
  const cells = Array.from({ length: 11 }, (_, score) => {
    const link = new URL("https://example.com/nps");
    link.searchParams.set("score", String(score));
    link.searchParams.set("r", respondent);
    link.searchParams.set("wave", wave);
    return `<td><a href="${link}" style="display:block;padding:8px 10px">${score}</a></td>`;
  });
  return `<table role="presentation"><tr>${cells.join("")}</tr></table>
<p>0 is not at all likely, 10 is extremely likely.</p>`;
}
```

メールは質問と数字の並びだけにしてください。前置きが長いとスマートフォンではスケールが画面の下に押し出されます。回答の大半は、スクロールせずに数字をクリックする人から届きます。

## ランディングページ

これをサイトの `/nps` に置きます。0〜10 のスケールを描画し、リンクのスコアをあらかじめ選択し、そのスコアに合った追加質問を設定し、本人が送信を押したときにだけ `fetch` で送信します。ページ上でスコアを変えると、質問もそれに合わせて変わります。

`nps.html`:

```html
<form id="nps" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>How likely are you to recommend us to a friend or colleague?</legend>
    <div id="nps-scale"></div>
  </fieldset>
  <label for="reason" id="reason-label">What's the main reason for your score?</label>
  <textarea id="reason" name="reason" rows="4" maxlength="4000"></textarea>
  <input type="hidden" name="respondent" />
  <input type="hidden" name="wave" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="nps-status" role="status"></p>
</form>

<script>
  const QUESTIONS = [
    [6, "What disappointed you most?"],
    [8, "What would make it a 10 for you?"],
    [10, "What do you value most about it?"],
  ];
  const params = new URLSearchParams(location.search);
  const form = document.getElementById("nps");
  const label = document.getElementById("reason-label");
  const scale = document.getElementById("nps-scale");

  for (let score = 0; score <= 10; score += 1) {
    scale.insertAdjacentHTML(
      "beforeend",
      `<label><input type="radio" name="score" value="${score}" required /> ${score}</label>`,
    );
  }

  function askFor(score) {
    label.textContent = QUESTIONS.find(([top]) => score <= top)[1];
  }

  const preset = Number(params.get("score"));
  if (params.has("score") && Number.isInteger(preset) && preset >= 0 && preset <= 10) {
    form.elements.score.value = String(preset);
    askFor(preset);
  }
  form.elements.respondent.value = params.get("r") ?? "";
  form.elements.wave.value = params.get("wave") ?? "";
  scale.addEventListener("change", (event) => askFor(Number(event.target.value)));

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const status = document.getElementById("nps-status");
    status.textContent = "Sending";
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) {
      form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks for answering." }));
      return;
    }
    status.textContent = result?.error?.fields?.score?.[0] ?? result?.error?.message ?? "Couldn't send it. Try again.";
  });
</script>
```

> このページはリンクなしでも動きます。`/nps` を直接開いた人には、何も選択されていないスケールが表示されます。デフォルトを設定しない限り、その回答は `wave` がないため拒否され、迷い込んだ訪問が数字に混ざりません。

## スコアを計算する

SDK で回ごとのすべての回答を読み、回答者ごとに最新の回答だけを残して、式を当てはめます。`IKIFORM_SECRET_KEY` にシークレットキーを入れて実行してください。

`nps.mjs`:

```js
import { createClient } from "@ikiform/sdk";

const WAVE = process.argv[2] ?? "2026-q4";
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const latest = new Map();

for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received" })) {
  const { score, respondent, wave } = submission.fields;
  if (wave !== WAVE) continue;
  const key = respondent || submission.id;
  const seen = latest.get(key);
  if (!seen || seen.at < submission.created_at) latest.set(key, { score, at: submission.created_at });
}

const scores = [...latest.values()].map((answer) => answer.score);
const promoters = scores.filter((value) => value >= 9).length;
const detractors = scores.filter((value) => value <= 6).length;
const nps = Math.round(((promoters - detractors) / scores.length) * 100);

console.log(`${WAVE}: ${scores.length} people, ${promoters} promoters, ${detractors} detractors, NPS ${nps}`);
```

気が変わって 2 つ目の数字をクリックする人がいるので、スクリプトは回答者ごとに回答を 1 つだけ残します。Ikiform はメールアドレスで重複した送信をまとめたり拒否したりしません。24 時間以内のまったく同じ再送信は、スパムスコアを上げるだけです。重複はリストを管理している場所か、エクスポートするときに取り除いてください。

## 動作を確認する

1. `/nps?score=3&r=test_respondent_0001&wave=2026-q4` を開きます。3 が選択され、質問は「What disappointed you most?」になっています。
2. 9 をクリックします。質問が「What do you value most about it?」に変わります。
3. 同じリンクを `curl` で開きます。何も送信されていないので、**Submissions** には何も増えません。
4. 送信を押します。回答の `score` は数値の 9 になっています。
5. `node nps.mjs 2026-q4` を実行します。1 人、推奨者 1 人と表示されます。

## ハマりどころ

- 同じ顧客にアンケートを送りすぎないでください。四半期に 1 回などの間隔を決め、回答者のリストは自分の側で管理します。
- サンプルが少ないと値が大きく振れます。回答が 20 件なら、1 人で NPS が 5 ポイント動きます。スコアの横に人数も載せましょう。
- 低いスコアには返信する価値があります。ピラーページの[低評価アラート](https://ikiform.com/ja/guides/feedback-form.md#routing)のような Webhook のリレーで、`score <= 6` を条件に批判者をチームのチャットに送ってください。
- アンケートの[フォームテンプレート](https://ikiform.com/forms/survey)は、長めのアンケートの中でスコアを尋ねます。単発の調査には向いていますが、NPS の質問を単独で尋ねるほうが回答は集まります。
- Ikiform はフォームを入力した本人にはメールを送りません。通知メールが届くのはあなたとチームだけなので、自動返信、確認メール、ダブルオプトインはありません。それらは [Webhook](https://ikiform.com/ja/docs/webhooks.md) をきっかけに、自分のメールツールから送ってください。

## トラブルシューティング

| 問題 | 対処法 |
| --- | --- |
| `Must be at most 10` 付きの `422`、または整数のエラー | スコアがスケール以外の場所から来ています。たとえば手で書き換えた URL です。ページがリンクから受け付けるのは 0〜10 だけです。メールテンプレートのリンクを確認してください。 |
| `wave` を示す `422` | リンクに `wave` が含まれていないか、`2026-q4` のような形式の値に一致していません。メールテンプレートを直すか、ページのスクリプトでデフォルトを設定してください。 |
| `403 origin_not_allowed` が返る、またはすべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルテスト用の `localhost` を追加してください。登録したドメインのサブドメインは許可されます。 |
| `429 rate_limited` | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。`Retry-After` まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |

## よくある質問

### NPS の質問をメールに入れるには？

0〜10 の 11 個のリンクをメールに入れ、それぞれがスコアを URL に含めて自分のサイトのページを開くようにします。ページはそのスコアをあらかじめ選択し、追加の質問を尋ね、本人が送信を押したときに回答を記録します。

### Ikiform の評価フィールドを NPS に使えますか？

いいえ。評価は 1 から最大 10 までで、NPS には 0 が必要です。min 0、max 10、integer true の数値フィールドを使ってください。

### NPS はどう計算しますか？

9 か 10 の回答の割合から 0〜6 の回答の割合を引きます。7 と 8 は合計に含めるだけで、それ以外では無視します。結果は −100 から 100 になります。

### リンクがクリックされた時点でスコアを記録しないのはなぜですか？

メールのセキュリティスキャナーは、受信したメールのリンクを開いて確認します。クリック時に記録すると、そうした訪問も回答として数えてしまいます。送信を押したときだけ記録すれば、数字は正確なままです。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [解約アンケート](https://ikiform.com/ja/guides/feedback-form/cancellation-survey.md): SaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。
- [ベータテスターのフィードバック](https://ikiform.com/ja/guides/feedback-form/beta-tester-feedback.md): テスターとビルドごとの個別リンク、2 つの評価スケール、各ビルドが良くなったかがわかる週次サマリーを備えたベータ版フィードバックフォームです。
- [React フィードバックウィジェット](https://ikiform.com/ja/guides/feedback-form/react-feedback-widget.md): React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
