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')
}
これで機能自体は動くんですが、パスワードマネージャーの保存ダイアログが出てくれません。
🔍 なぜ表示されないのか
ブラウザのパスワードマネージャーは以下の条件で保存ダイアログを表示します:
-
<form>要素がPOSTで送信される -
autocomplete="email"やautocomplete="new-password"属性を持つ入力フィールドがある - ページ遷移(ナビゲーション)が発生する
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は便利ですが、ブラウザの機能と組み合わせるときには一工夫必要なケースがあります。
今回のポイント:
- 隠しフォーム + ダミーエンドポイントでPOSTリクエストを発生させる
-
autoComplete属性を正しく設定する - 303ステータスコードでPRGパターンを実現する
- セッションでリロード時のデータ保持に対応する
Server Actionsの内部実装(fetch API)を理解しておくと、こういった回避策も思いつきやすくなります🙆♀️
Discussion