Skip to content

Instantly share code, notes, and snippets.

@nakanoasaservice
Last active October 11, 2025 15:05
Show Gist options
  • Select an option

  • Save nakanoasaservice/67fca619d58113a2518781269bfb7973 to your computer and use it in GitHub Desktop.

Select an option

Save nakanoasaservice/67fca619d58113a2518781269bfb7973 to your computer and use it in GitHub Desktop.
Next.jsでトーストライブラリsonnerをServer Actionsから呼べるようにするラッパー
"use client"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { toast } from "sonner"
import * as v from "valibot"
import { TOAST_COOKIE_NAME, ToastCommandSchema } from "./commons"
/**
* クッキーを取得する
* @param name クッキー名
* @returns クッキーの値(存在しない場合はnull)
*/
function getCookie(name: string): string | null {
try {
if (typeof document === "undefined") return null
const cookies = document.cookie.split("; ")
const cookie = cookies.find((c) => c.startsWith(`${name}=`))
if (!cookie) return null
// "name=value" 形式から "value" 部分を取得
return decodeURIComponent(cookie.substring(name.length + 1))
} catch (error) {
console.error(`[トースト] クッキー「${name}」の取得に失敗しました:`, error)
return null
}
}
/**
* クッキーを削除する
* @param name クッキー名
*/
function deleteCookie(name: string): void {
try {
if (typeof document === "undefined") return
// 有効期限を過去に設定することでクッキーを削除
// eslint-disable-next-line functional/immutable-data -- クッキーを削除するため
document.cookie = `${name}=; path=/; max-age=0`
} catch (error) {
console.error(`[トースト] クッキー「${name}」の削除に失敗しました:`, error)
}
}
/**
* トーストメッセージを処理して表示する
* @param cookieValue クッキーの値
*/
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
if (!type) {
toast(message, data)
} else {
toast[type](message, data)
}
} catch (parseError) {
if (parseError instanceof SyntaxError) {
console.error(
"[トースト] メッセージのJSONパースに失敗しました:",
parseError,
)
} else {
console.error(
"[トースト] メッセージの処理中に予期しないエラーが発生しました:",
parseError,
)
}
// フォールバックとして基本的なエラーメッセージを表示
toast.error("通知の表示中にエラーが発生しました")
}
}
/**
* サーバーからのトーストメッセージを表示するクライアントコンポーネント
* パスが変更されるたびにクッキーからメッセージを取得して表示します
*/
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]) // パスが変わるたびに実行
// 実際にはUIを描画しないコンポーネント
return null
}
import * as v from "valibot"
export const TOAST_COOKIE_NAME = "redirect-toast"
export type RedirectToastMessage = string | number | boolean
export const RedirectToastMessageSchema = v.union([
v.string(),
v.number(),
v.boolean(),
])
const ToastTypeSchema = v.picklist([
"success",
"info",
"warning",
"error",
"loading",
])
export type ToastType = v.InferOutput<typeof ToastTypeSchema>
export const ToastDataSchema = v.object({
id: v.optional(v.union([v.string(), v.number()])),
richColors: v.optional(v.boolean()),
invert: v.optional(v.boolean()),
closeButton: v.optional(v.boolean()),
dismissible: v.optional(v.boolean()),
duration: v.optional(v.number()),
description: v.optional(RedirectToastMessageSchema),
delete: v.optional(v.boolean()),
position: v.optional(
v.picklist(["top-left", "top-right", "bottom-left", "bottom-right"]),
),
})
export type ToastData = v.InferOutput<typeof ToastDataSchema>
export const ToastCommandSchema = v.object({
type: v.optional(ToastTypeSchema),
message: RedirectToastMessageSchema,
data: v.optional(ToastDataSchema),
})
export type ToastCommand = v.InferOutput<typeof ToastCommandSchema>
import "server-only"
import { cookies } from "next/headers"
import {
type RedirectToastMessage,
TOAST_COOKIE_NAME,
type ToastCommand,
type ToastData,
type ToastType,
} from "./commons"
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) {
console.error(
`[トースト] 通知の設定に失敗しました: ${error instanceof Error ? error.message : String(error)}`,
)
// 必要に応じてエラーモニタリングサービスに送信するなどの処理を追加
}
}
type RedirectToast = ReturnType<typeof createRedirectToast> & {
[key in ToastType]: ReturnType<typeof createRedirectToast>
}
export const redirectToast = createRedirectToast() as RedirectToast
// eslint-disable-next-line functional/immutable-data -- sonnerのAPIをサーバー上で再現するため
Object.assign(redirectToast, {
success: createRedirectToast("success"),
error: createRedirectToast("error"),
info: createRedirectToast("info"),
warning: createRedirectToast("warning"),
loading: createRedirectToast("loading"),
} satisfies { [key in ToastType]: ReturnType<typeof createRedirectToast> })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment