🍞

トーストライブラリsonnerをNext.jsのServer Actionsで使えるようにした

に公開

はじめに

こんにちは!株式会社INFLUでサブプロダクトのPOをやっている、Nakano as a Serviceです。

Next.jsのServer Actionsは、クライアントとサーバー間のやり取りを劇的にシンプルにしてくれます。特にフォームの送信後などにredirect()を呼び出すだけで、サーバーサイドでページ遷移を完結できるのは非常に強力です。

しかし、このredirect()を使ったフローでは一つ悩ましい問題がありました。それは、「リダイレクトしたのページで『保存しました』のようなトースト通知を出したい」という要求に簡単には応えられないことです。

この記事では、その課題を解決するために作成した、トーストライブラリsonnerの小さなラッパーを紹介します。サーバーサイドでCookieにトースト情報を保存し、クライアントサイドでそれを読み込んで表示するというシンプルなアーキテクチャで、Server Actionsの利便性を損なうことなく、安全で堅牢なトースト表示を実現します。

この記事を読めば、以下のことがわかります。

  • Server Actionsのredirect()とトースト表示の両立が難しい理由
  • Cookieを活用した効率的なアーキテクチャ
  • 具体的な実装と使い方
  • valibotを用いたスキーマ検証や、Cookieのセキュリティ設定といった工夫

また今回紹介するコードの全体は、以下のGistで確認できます。

https://gist.github.com/nakanoasaservice/67fca619d58113a2518781269bfb7973

背景と課題

Server Actionsの魅力の一つは、サーバー側で直接リダイレクトを行える redirect() 関数の存在です。これにより、クライアント側のコードを非常にシンプルに保てます。

例えば、ユーザー情報を更新するフォームでは、Server Actionはこれだけで済みます。

理想的なServer Action
// actions.ts
"use server"

import { redirect } from "next/navigation"

export async function updateUser(formData: FormData) {
  // ... DB更新処理 ...
  
  // 処理が終わったらダッシュボードにリダイレクト
  redirect("/dashboard")
}

// some-form.tsx
// クライアントコンポーネントは不要で、シンプルなフォームで済みます。
<form action={updateUser}>
  {/* ... */}
</form>

"use client"useRouterも不要で、非常に宣言的です。しかし、ここには一つ大きな問題があります。このままだと、ユーザーはなぜリダイレクトされたのかを知るすべがありません。「更新が成功した」というフィードバック(トースト)を表示する機会がないのです。

では、どうすればトーストを表示できるのでしょうか。一般的な方法は、この便利なredirect()の使用を諦め、代わりにServer Actionからステータスを返し、クライアント側でトースト表示とページ遷移の両方をハンドリングすることです。

その場合、以下のような流れになります。

  1. クライアントからServer Actionsを呼び出す。
  2. Server Actionsは処理を完了し、成功/失敗のステータスを返す。
  3. クライアントはステータスを受け取り、toast.success()などでトーストを表示する。
  4. その後、router.push()でクライアントサイドでページ遷移を行う。

例えば、以下のようなコードになります。

従来の非効率な実装例
// actions.ts
"use server"

export async function updateUser(formData: FormData) {
  try {
    // ... DB更新処理 ...
    // redirect()は使わず、成功/失敗を返す
    return { success: true }
  } catch (e) {
    return { success: false, error: "更新に失敗しました" }
  }
}

// some-form.tsx
"use client"

import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { updateUser } from "./actions"

export function SomeForm() {
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const result = await updateUser(formData)
    if (result.success) {
      // 1. まずトーストを表示
      toast.success("更新しました")
      // 2. その後クライアントサイドで遷移
      router.push("/dashboard")
    } else {
      toast.error(result.error)
    }
  }

  return <form action={handleSubmit}>{/* ... */}</form>
}

この方法は機能しますが、Server Actionsが持つredirect()のシンプルさと比べると、いくつかの点で非効率です。

  • 余分な通信と待機時間: クライアントは「Server Actionの実行」と「ページ遷移」で合計2回のサーバーとのやり取りが必要です。redirect()なら1回で済むため、ユーザーの待ち時間がわずかに増えます。
  • クライアント側の責務増加: 本来サーバー側で完結できるページ遷移の判断を、クライアント側が担当することになります。フォームコンポーネントは useRouter を呼び出し、レスポンスを解釈して router.push を実行するという追加のロジックを持つ必要があり、コンポーネントが複雑化します。
  • コードの煩雑化: 本来 <form action={updateUser}> と書くだけで済むところが、上記のようにクライアントコンポーネントでラップし、handleSubmitのような関数を別途用意する必要があり、定型的なコードが増えてしまいます。
  • UXの違和感: フォームを送信したページで一瞬トーストが表示されてから次のページへ遷移するため、ユーザー体験としてやや不自然に感じられることがあります。理想は、遷移が完了した新しいページで結果の通知を受け取ることです。

Server Actions内でredirect()を直接呼び出すと、これらの問題は解決されますが、今度はクライアント側でトーストを表示するタイミングを失ってしまいます。

私たちの目標は、redirect()のUXとDXを維持したまま、リダイレクト先でトーストを表示することでした。

解決アプローチ:Cookieを使った状態の引き継ぎ

この課題を解決するため、私たちはサーバーとクライアント間で状態を安全に引き継ぐ方法としてCookieを利用しました。

アーキテクチャは非常にシンプルです。

  1. サーバーサイド: Server Actions内でredirect()を呼び出す直前に、表示したいトーストの種類(success, errorなど)、メッセージ、オプションをJSON形式でCookieに書き込みます。
  2. クライアントサイド: ルートレイアウトに配置したコンポーネントがページのパス変更を検知します。特定の名前のCookieが存在すれば、その値を読み取ってsonnerでトーストを表示し、すぐにそのCookieを削除します。

この方法なら、Server Actionsはredirect()を呼び出すだけでよく、クライアント側はパスが変更されるたびにCookieをチェックするだけです。状態の受け渡しが疎結合になり、それぞれの責務が明確になります。

フローを図で表すと以下のようになります。

実装

次に、このアーキテクチャを支える主要なコードを見ていきましょう。

1. サーバーサイド:sonnerライクなAPIを提供するラッパー

Server Actionsから簡単に使えるように、sonnerとほぼ同じAPIを持つラッパー関数redirectToastを用意しました。

server.ts
import "server-only"
import { cookies } from "next/headers"
// ...

const createRedirectToast =
  (type?: ToastType) =>
  async (message: RedirectToastMessage, data?: ToastData) => {
    try {
      const command: ToastCommand = { type, message, data }

      const cookieStore = await cookies()

      cookieStore.set(TOAST_COOKIE_NAME, JSON.stringify(command), {
        secure: true,
        sameSite: "strict",
        httpOnly: false, // クライアントからアクセスできるようにfalse
        path: "/",
        maxAge: 30, // 30秒
      })
    } catch (error) {
      // ...
    }
  }

// ...

export const redirectToast = createRedirectToast() as RedirectToast

Object.assign(redirectToast, {
  success: createRedirectToast("success"),
  error: createRedirectToast("error"),
  info: createRedirectToast("info"),
  warning: createRedirectToast("warning"),
})

redirectToast.success("保存しました")のように呼び出すと、トーストの情報をJSONに変換し、Cookieに設定します。

ここで重要なのは、このCookieを安全かつ今回の目的に最適化して設定することです。各オプションは、セキュリティを確保しつつ、クライアント側での一度きりの読み取りという役割を確実に果たせるように慎重に選んでいます。

  • セキュリティの確保 (secure, sameSite):
    • secure: true: HTTPS通信時のみCookieが送信されるようにし、通信経路での盗聴リスクを軽減します。
    • sameSite: "strict": CSRF(クロスサイトリクエストフォージェリ)攻撃を防ぐため、外部サイトからのリクエスト時にはCookieが送信されないようにします。
  • 機能要件の達成 (httpOnly):
    • httpOnly: false: 今回の目的の核心である、クライアントサイドのJavaScript (<RedirectToast />コンポーネント) からCookieを読み取れるようにするために、この設定は必須です。trueにするとJavaScriptからアクセスできなくなります。
  • 堅牢性の向上 (path, maxAge):
    • path: "/": どのページにリダイレクトされてもCookieが読み取れるように、サイト全体で有効にしています。
    • maxAge: 30: クライアント側でのCookie削除処理が万が一失敗した場合でも、30秒後にはブラウザが自動的にCookieを破棄するように設定しています。これにより、不要な情報が意図せず残り続ける事態を防ぎます。

2. クライアントサイド:Cookieを監視するRedirectToastコンポーネント

次に、クライアント側でCookieを読み取り、トーストを表示するコンポーネントです。このコンポーネントはUIを持たず、副作用の管理のみを行います。

client.tsx
"use client"

import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { toast } from "sonner"
// ...

export function RedirectToast() {
  const pathname = usePathname()

  useEffect(() => {
    try {
      const toastCookieValue = getCookie(TOAST_COOKIE_NAME)
      if (!toastCookieValue) return

      // 一度使ったら必ず削除
      deleteCookie(TOAST_COOKIE_NAME)

      processToastMessage(toastCookieValue)
    } catch (error) {
      console.error("[トースト] 処理中に予期しないエラーが発生しました:", error)
    }
  }, [pathname]) // パスが変わるたびに実行

  return null
}

usePathname()フックで現在のパスを取得し、useEffectの依存配列に設定することで、ページ遷移が発生するたびにCookieのチェックが実行されます。

Cookieが見つかった場合は、その内容をパースしてトーストを表示し、すぐにCookieを削除します。 これにより、ユーザーがページをリロードした際に同じトーストが何度も表示されるのを防ぎます。

3. 安全性の確保:valibotによるスキーマ検証

Cookieは何かと改変される可能性があるため、受け取った値をそのまま信用するのは危険です。そこで、私たちはvalibotというスキーマ検証ライブラリを導入しました。

サーバーとクライアントで共有するcommons.tsファイルに、トースト情報のスキーマを定義します。

commons.ts
import * as v from "valibot"

export const TOAST_COOKIE_NAME = "redirect-toast"

// ...

export const ToastDataSchema = v.object({
  id: v.optional(v.union([v.string(), v.number()])),
  duration: v.optional(v.number()),
  description: v.optional(v.string()),
  // ... sonnerの他のオプション
})

export const ToastCommandSchema = v.object({
  type: v.optional(v.enum(ToastType)),
  message: v.string(), // messageは必須
  data: v.optional(ToastDataSchema),
})

そして、クライアントサイドでCookieの値をパースした後、このスキーマを使ってバリデーションを行います。

client.tsx
function processToastMessage(cookieValue: string): void {
  try {
    const parsedValue = JSON.parse(cookieValue) as unknown
    // バリデーションを実行
    const command = v.safeParse(ToastCommandSchema, parsedValue)

    if (!command.success) {
      console.error(
        "[トースト] メッセージのバリデーションに失敗しました:",
        command.issues,
      )
      return
    }

    const { type, message, data } = command.output
    // ... バリデーション済みの値を使ってトーストを表示
  } catch (parseError) {
    // ...
  }
}

これにより、不正な形式のデータが渡された場合でも、安全に処理を中断し、意図しない動作を防ぐことができます。

使い方

この仕組みの使い方は非常に簡単です。

  1. <RedirectToast />の設置

    まず、アプリケーションのルートレイアウト(layout.tsxなど)に、<RedirectToast />コンポーネントを配置します。

    app/layout.tsx
    import { RedirectToast } from "@/features/toast/RedirectToast"
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="ja">
          <body>
            {children}
            <RedirectToast />
          </body>
        </html>
      )
    }
    
  2. Server Actionsからの呼び出し

    次に、Server Actions内で、redirect()の前にredirectToastを呼び出します。

    app/some/actions.ts
    "use server"
    
    import { redirect } from "next/navigation"
    import { redirectToast } from "@/features/toast/server"
    
    export async function updateUser(formData: FormData) {
      // ... ユーザー情報の更新処理 ...
    
      redirectToast.success("ユーザー情報を更新しました。")
      redirect("/dashboard")
    }
    

たったこれだけです。これで、/dashboardにリダイレクトされた後、「ユーザー情報を更新しました。」というトーストが表示されます。

まとめ

本記事では、Next.jsのServer Actionsでredirect()を使いながら、リダイレクト先のページでトーストを表示するためのアーキテクチャと、その具体的な実装について解説しました。

  • サーバーサイドでCookieにトースト情報を書き込む
  • クライアントサイドでパスの変更を検知し、Cookieを読み込んでトーストを表示・削除する
  • valibotでスキーマを共有・検証し、安全性を高める

この小さなラッパーを導入することで、Server Actionsの強力な機能を最大限に活かしつつ、ユーザーへのフィードバックも損なわない、優れた開発体験(DX)とユーザー体験(UX)を両立できます。

このアイデアが、皆さんのNext.jsアプリケーション開発の助けになれば幸いです。

Discussion