Next.js × Echo:Server Actionsでcookieをセットしてみた!
はじめに
Next.jsを使って開発する場合、APIサーバーを別で実装するケースがしばしばあると思います。
私の所属先でも、バックエンドにDjangoを使用しています。
しかし、Next.jsは本来フルスタックフレームワークであるため、APIサーバーを別途用意することは想定されていません🥲
そのため、設計上の課題に直面することが度々あります。
今回は個人的に苦戦した、Server ActionsでEchoサーバーから取得したトークンをcookieにセットする方法を紹介します!
技術スタック
バックエンドにはGoのフレームワークであるEchoを採用しました(特に理由はないです)。
また、Webサーバーを設けることでEchoとNext.jsのドメインを統一(/localhost)します。
これはトークンのSameSite属性をStrictに設定して、クロスサイトのセキュリティを強化するためです。
領域 | 技術スタック | バージョン |
---|---|---|
バックエンド | Echo | 4.12.0 |
Echo JWT | 4.1.0 | |
フロントエンド | Next.js (App Router) | 15.0.2 |
React | 18.3.1 | |
Zod | 3.23.8 | |
サーバー | Nginx | 1.21 |
コンテナ | Docker | - |
流れ
- ページにアクセスしたら、Route Handler経由でcsrf tokenを取得する
- ログインボタンを押下したら、server action経由でJWT tokenを取得し、cookieにセットする。
完成系
先に完成系を載せておきます!
/app/api/csrf/route.ts
import { NextResponse } from "next/server";
export async function GET() {
try {
const res = await fetch(`${process.env.HOST_DOMAIN}/csrf`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res;
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
/comopnents/form.tsx
// comopnents/form.tsx
"use client";
import { logIn } from "@/actions";
import { useActionState, useCallback } from "react";
export const Form = () => {
// csrf tokenを取得する
const getCsrfToken = useCallback(async () => {
try {
const res = await fetch(`/api/csrf`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
} catch (error) {
if (error instanceof Error) {
alert(error.message);
}
}
}, []);
useEffect(() => {
getCsrfToken();
}, [getCsrfToken]);
// server action
const [state, action, isPending] = useActionState(logIn, null);
// ログイン用のフォーム
return (
<form
className="flex flex-col items-center justify-center gap-4"
action={formAction}
>
<input
className="w-50 border border-2 rounded"
type="email"
name="email"
/>
<input
className="w-50 border border-2 rounded"
type="password"
name="password"
/>
{state?.errors && (
<div>
<p aria-live="polite">{state?.errors.email}</p>
<p aria-live="polite">{state?.errors.password}</p>
</div>
)}
<button
className="bg-blue-500 text-white rounded p-2 disabled:bg-gray-300"
disabled={isPending}
>
Submit
</button>
</form>
);
};
/actions.ts
"use server"
import { schema } from "@/validation";
import { cookies } from "next/headers";
import { z } from "zod";
// validation
const schema = z.object({
email: z
.string({
required_error: "Email is required",
invalid_type_error: "Email is invalid",
})
.email({
message: "Email is invalid",
}),
password: z
.string({
required_error: "Password is required",
invalid_type_error: "Password is invalid",
})
.min(6, {
message: "Password must be at least 6 characters",
}),
});
// server actions
export const logIn = async (prevState: unknown, formData: FormData) => {
const validatedFields = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const setCookies = allCookies
.map((cookie) => `${cookie.name}=${cookie.value};`)
.join(" ");
const csrftoken = cookieStore.get("_csrf");
const res = await fetch(`${process.env.HOST_DOMAIN}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": csrftoken?.value || "",
Cookie: setCookies,
},
body: JSON.stringify({
email: `${formData.get("email")}`,
password: `${formData.get("password")}`,
}),
});
if (!res.ok) {
const error = await res.json();
console.error(error);
throw new Error(error);
}
const copyCookie = res.headers.get("set-cookie");
const tokenPart = copyCookie?.indexOf("token=");
const token = tokenPart ? copyCookie?.substring(tokenPart) : "";
const tokenValue = token?.match(/token=(.*?);/)?.[1];
const expiresValue = token?.match(/Expires=(.*?);/)?.[1];
cookieStore.set({
name: "token",
value: tokenValue || "",
sameSite: "strict",
secure: true,
httpOnly: true,
expires: new Date(expiresValue || ""),
});
redirect("/tasks");
};
Echo
// csrf
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
// Cookieを送信できるパスを指定
CookiePath:"/",
// Cookieのドメインを指定
CookieDomain: os.Getenv("API_DOMAIN"),
// JavaScriptからCookieにアクセスできないようにする
CookieHTTPOnly: true,
// CookieのSameSite属性をStrictに設定
CookieSameSite:http.SameSiteStrictMode,
// Cookieの有効期限を設定
// defaultは86400秒
CookieMaxAge: 600,
// CookieのSecure属性をtrueに設定
CookieSecure: true,
}))
// ログイン処理
func (uc *userController) LogIn(c echo.Context) error {
user := model.User{}
if err := c.Bind(&user); err != nil {
return c.JSON(http.StatusBadRequest,err.Error())
}
// userUsecase内でemail,pwの一致確認とtokenの生成を行っている。
token,err := uc.userUsecase.Login(user)
if err != nil {
fmt.Println(err)
return c.JSON(http.StatusInternalServerError,err.Error())
}
cookie := new(http.Cookie)
cookie.Name = "token"
cookie.Value = token
cookie.Expires = time.Now().Add(24 * time.Hour)
cookie.Path = "/"
cookie.Domain = os.Getenv("API_DOMAIN")
cookie.HttpOnly = true
cookie.Secure = true
cookie.SameSite = http.SameSiteStrictMode
c.SetCookie(cookie)
return c.JSON(http.StatusOK,echo.Map{
"message":"success",
})
}
Route Handler経由でcsrf tokenを取得する
Server ActionsはPOSTメソッドにしか対応していないため、処理にはRoute Handlerを使用します。
Behind the scenes, actions use the POST method, and only this HTTP method can invoke them.
公式ドキュメントによると、レスポンスヘッダーに Set-Cookie
が含まれていればCookieとしてセット可能であるため、Echoサーバーからのレスポンスをそのまま返します。
const getCsrfToken = useCallback(async () => {
try {
const res = await fetch(`/api/csrf`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
} catch (error) {
if (error instanceof Error) {
alert(error.message);
}
}
}, []);
useEffect(() => {
getCsrfToken();
}, [getCsrfToken]);
import { NextResponse } from "next/server";
export async function GET() {
try {
const res = await fetch(`${process.env.HOST_DOMAIN}/csrf`);
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res;
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
Server Actions経由で取得したCookieをセットする
では次にServer Actions経由で取得したCookieをセットする方法です。
まず、ログイン用のフォームを作成しました。
Server Actionsにおけるフォームの状態管理はuseFormState
であると思っていたのですが、実際はuseActionState
を使用しました。
useFormState
からuseActionState
に変更されたのはReact 19からという認識でしたが、React v18.3.1の公式ドキュメントにはすでにuseActionState
が記載されています。
ただ、Next.jsの公式はuseFormState
のままなので、少し困惑してしまいました。。
export const Form = () => {
// server action
const [state, action, isPending] = useActionState(logIn, null);
// ログイン用のフォーム
return (
<form
className="flex flex-col items-center justify-center gap-4"
action={formAction}
>
<input
className="w-50 border border-2 rounded"
type="email"
name="email"
/>
<input
className="w-50 border border-2 rounded"
type="password"
name="password"
/>
{state?.errors && (
<div>
<p aria-live="polite">{state?.errors.email}</p>
<p aria-live="polite">{state?.errors.password}</p>
</div>
)}
<button
className="bg-blue-500 text-white rounded p-2 disabled:bg-gray-300"
disabled={isPending}
>
Submit
</button>
</form>
);
};
フォームのSubmitボタンを押すとServer Actionsが実行されます。
このServer Actionsでは、バリデーションとログインのためのfetch処理を行います。
バリデーションはZodを使って実装しています。
このlogIn関数では、schema.safeParse
を用いて入力データをバリデーションしています。
バリデーションが成功しなければ、エラーメッセージを返し、成功すれば次の処理へと進みます。
const schema = z.object({
email: z
.string()
.email({
message: "Email is invalid",
}),
password: z
.string()
.min(6, {
message: "Password must be at least 6 characters",
}),
});
export const logIn = async (prevState: unknown, formData: FormData) => {
const validatedFields = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
}
バリデーションが成功したら、fetch処理が行われます。
Client SideとServer Actions間でCookieの受け渡しを行うには、next/headers
を使う必要があります(他に方法があれば教えてください!)。
そのため、受け取ったレスポンスを元に、再度cookie.setを行うという少し強引な方法を取っています。
この際、Expiresの設定はレスポンスの値に合わせないと、正しい期限が反映されない可能性があるため注意が必要です。
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const setCookies = allCookies
.map((cookie) => `${cookie.name}=${cookie.value};`)
.join(" ");
const csrftoken = cookieStore.get("_csrf");
const res = await fetch(`${process.env.HOST_DOMAIN}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": csrftoken?.value || "",
Cookie: setCookies,
},
body: JSON.stringify({
email: `${formData.get("email")}`,
password: `${formData.get("password")}`,
}),
});
if (!res.ok) {
const error = await res.json();
console.error(error);
throw new Error(error);
}
const copyCookie = res.headers.get("set-cookie");
const tokenPart = copyCookie?.indexOf("token=");
const token = tokenPart ? copyCookie?.substring(tokenPart) : "";
const tokenValue = token?.match(/token=(.*?);/)?.[1];
const expiresValue = token?.match(/Expires=(.*?);/)?.[1];
cookieStore.set({
name: "token",
value: tokenValue || "",
sameSite: "strict",
secure: true,
httpOnly: true,
expires: new Date(expiresValue || ""),
});
redirect("/tasks");
このコードでは、まず既存のすべてのCookie
を取得し、それをsetCookies
としてまとめ、/login
へのPOSTリクエストに渡します。
その後、レスポンスから新たにセットされたSet-Cookie
ヘッダーを取得し、Expires
情報を反映させて、再度cookie.set
を使って保存しています。
最後に、リダイレクトでログイン後の画面に遷移させる流れです。
Server ActionsでCookieを扱うメリットはあるのか?
今回のように、APIサーバーを別に用意するケースでは、Server ActionsでCookieを扱うメリットはあまりないと感じました。
なので、Cookieの操作はRoute Handlerで行うのが適切だと思います。
ただし、Route Handlerを使用する場合、Next.jsのSuspense
による機能(error.tsx
やloading.tsx
)はクライアント側の状態を検知できないというデメリットもあります。
そのため、Server ActionsとRoute Handlerを組み合わせて使うと、Suspense機能のエラーバリデーションと従来のエラーバリデーションをそれぞれ別々に管理する必要が出てきます。
サスペンスはエフェクトやイベントハンドラ内でデータフェッチが行われた場合にはそれを検出しません。
終わりに
別にAPIサーバーを用意しているケースでは、Route Handlerで統一した方が無難だなと思いました。
ただ、Route Handlerで統一してしまうと、Next.jsで使用できる機能に制限がかかるのは痛いですね。。
将来的にServer Actionsが他のHTTPメソッドにも対応すれば、実装がさらにスムーズになりそうです。
今回の検討を通じて、Next.jsは基本的にフルスタックで完結するように設計されていることを改めて実感しました。
今後のアップデートで、さらに柔軟な機能が増えることを期待しています!💫
Discussion
Echoサーバーからのトークン取得とCookie設定のフローや、Reactのバージョン変化によるuseFormStateからuseActionStateへの変更に対する戸惑いには、フルスタック開発ならではのリアルな悩みを感じました。