🔐

Next.js Server Actionsでパスワードマネージャーが動かない問題の解決法

に公開

はじめに

Next.js App RouterでServer Actionsを使っていると、「ブラウザのパスワードマネージャーが保存ダイアログを出してくれない」という問題に遭遇することがあります。

従来のフォーム送信では、ブラウザがPOSTリクエストを検知してパスワード保存を促してくれていました。でもServer Actionsは内部的にfetch APIを使っているため、ブラウザは「Ajax通信」と認識してしまい、パスワード保存が動きません🙅‍♀️

この記事では、隠しフォームとダミーエンドポイントを組み合わせて、Server Actions使用時でもパスワードマネージャーを動作させる方法を紹介します🙌

📌 対象読者

  • Next.js App Router/Server Actionsを使用している方
  • 認証フロー(新規登録・ログイン)を実装している方
  • パスワードマネージャーのUXを改善したい方

🛠️ 環境

  • Next.js 14 / 15(App Router)
  • React 18 / 19
  • TypeScript

🤔 背景・課題

❌ Server Actionsだとパスワード保存が動かない

新規登録フローを実装していて、認証コード入力後の本登録処理をServer Actionで行っていました。

// 認証コード入力 → 本登録処理
const result = await verifyAndCompleteRegistration({ authCode })
if (result.success) {
  router.push('/complete')
}

これで機能自体は動くんですが、パスワードマネージャーの保存ダイアログが出てくれません。

🔍 なぜ表示されないのか

ブラウザのパスワードマネージャーは以下の条件で保存ダイアログを表示します:

  1. <form> 要素がPOSTで送信される
  2. autocomplete="email"autocomplete="new-password" 属性を持つ入力フィールドがある
  3. ページ遷移(ナビゲーション)が発生する

Server Actionsは内部的にfetch APIを使っているため、ブラウザは「フォーム送信」ではなく「Ajax通信」として認識します。そのため上記の条件を満たさず、パスワード保存が動作しないんです。

✅ 解決策

💡 アプローチ

Server Actionが成功したら、隠しフォームを使って実際のPOSTリクエストを発生させる方法で解決しました。

[認証コード入力]
    ↓ Server Action
[本登録処理完了]
    ↓ 隠しフォームをsubmit
[ダミーエンドポイント] POST /api/register/password-save
    ↓ 303リダイレクト
[完了画面] ← ここでパスワード保存ダイアログが表示される

💻 実装

1️⃣ 隠しフォームをコンポーネントに追加

認証コード入力画面に、パスワードマネージャー用の隠しフォームを追加します。

// VerificationForm.tsx
'use client'

import { useEffect, useRef, useState } from 'react'

interface PasswordManagerFormData {
  email: string
  password: string
}

export function VerificationForm() {
  const [formData, setFormData] = useState<PasswordManagerFormData | null>(null)
  const passwordFormRef = useRef<HTMLFormElement>(null)

  // formData が設定されたらパスワードマネージャー用フォームを submit
  useEffect(() => {
    if (formData && passwordFormRef.current) {
      passwordFormRef.current.submit()
    }
  }, [formData])

  const handleSubmit = async () => {
    // Server Action で本登録処理
    const result = await verifyAndCompleteRegistration({ authCode })

    if (result.success) {
      // 成功したら隠しフォームにデータをセット
      setFormData({
        email: result.email,
        password: result.password,
      })
    }
  }

  return (
    <>
      {/* メインのフォーム */}
      <form onSubmit={handleSubmit}>
        <input type="text" name="authCode" />
        <button type="submit">本登録</button>
      </form>

      {/* パスワードマネージャー用の隠しフォーム */}
      <form
        ref={passwordFormRef}
        action="/api/register/password-save"
        method="POST"
        style={{ position: 'absolute', left: '-9999px', opacity: 0 }}
        aria-hidden="true"
      >
        <input
          type="email"
          name="email"
          autoComplete="email"
          value={formData?.email ?? ''}
          readOnly
          tabIndex={-1}
        />
        <input
          type="password"
          name="password"
          autoComplete="new-password"
          value={formData?.password ?? ''}
          readOnly
          tabIndex={-1}
        />
      </form>
    </>
  )
}

✨ ポイント

  • autoComplete="email"autoComplete="new-password" が重要
  • aria-hidden="true"tabIndex={-1} でアクセシビリティに配慮
  • position: absolute で画面外に配置

2️⃣ ダミーエンドポイントを作成

POSTリクエストを受け取って、完了画面にリダイレクトするだけのRoute Handlerを作ります。

// app/api/register/password-save/route.ts
import { NextResponse } from 'next/server'

export async function POST() {
  // 303: POST → GET に変換(PRGパターン)
  return NextResponse.redirect(
    `${process.env.NEXT_PUBLIC_APP_URL}/complete`,
    303
  )
}

✨ ポイント

  • ステータスコード 303 を使うことで、POST → GET に変換される
  • デフォルトの 307 だとPOSTメソッドが保持されてしまう
  • 受け取ったデータは使わない(必要なデータはセッションに保存済み)

3️⃣ セッションでデータを保持(リロード対応)

ページをリロードしたときにも本登録完了データを取得できるよう、セッションに保存しておきます。

// Server Action
export async function verifyAndCompleteRegistration({ authCode }) {
  const cookiesData = await cookies()

  // 既にセッションに完了データがある場合は再利用(リロード対応)
  const existingData = await getRegistrationCompleteData({ cookiesData })
  if (existingData) {
    return {
      success: true,
      email: existingData.email,
      password: existingData.password,
    }
  }

  // 本登録処理...
  const result = await definitiveRegister({ ... })

  // 完了データをセッションに保存
  await setRegistrationCompleteData({ cookiesData, data: result })

  return {
    success: true,
    email: result.email,
    password: result.password,
  }
}

🔄 フロー全体像

1. ユーザーが認証コードを入力して送信
2. Server Action で本登録処理を実行
3. 成功したら、完了データをセッションに保存
4. クライアントに email と password を返す
5. useEffect で隠しフォームに値をセットして submit
6. /api/register/password-save に POST リクエスト
7. ブラウザが「フォーム送信」を検知
8. 303 リダイレクトで完了画面に遷移
9. パスワードマネージャーが保存ダイアログを表示

⚠️ 注意点

🔢 なぜ 303 ステータスコードが必要か

NextResponse.redirect() のデフォルトは 307 Temporary Redirect です。307はHTTPメソッドを保持するため、POSTでリダイレクトされるとリダイレクト先でもPOSTになってしまいます。

これだとPRG(Post-Redirect-Get)パターンに反してしまい、ブラウザの「戻る」ボタンで「フォームの再送信」ダイアログが表示されてしまいます。

303 See Other を使うと、POST → GET に変換されて、正しいPRGパターンになります。

🔒 セキュリティについて

  • パスワードは生成されたものをそのまま返しています(本登録完了時に自動生成)
  • 隠しフォームのデータはクライアントサイドで一時的に保持されるだけで、実際の認証処理には使いません
  • 完了データはセッション(iron-session)で暗号化して保存しています

🎉 結果

この実装で、以下のブラウザで動作確認できました:

  • ✅ Chrome(デスクトップ/モバイル)
  • ✅ Safari(macOS/iOS)
  • ✅ Firefox
  • ✅ Edge

どれもパスワード保存ダイアログが正しく表示され、保存したパスワードがログイン画面で自動入力されるようになりました。

📝 まとめ

Server Actionsは便利ですが、ブラウザの機能と組み合わせるときには一工夫必要なケースがあります。

今回のポイント

  1. 隠しフォーム + ダミーエンドポイントでPOSTリクエストを発生させる
  2. autoComplete 属性を正しく設定する
  3. 303ステータスコードでPRGパターンを実現する
  4. セッションでリロード時のデータ保持に対応する

Server Actionsの内部実装(fetch API)を理解しておくと、こういった回避策も思いつきやすくなります🙆‍♀️

📚 参考

ラッコ株式会社

Discussion