🍪

Next.js × Echo:Server Actionsでcookieをセットしてみた!

2024/11/10に公開
1

はじめに

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 -

流れ

  1. ページにアクセスしたら、Route Handler経由でcsrf tokenを取得する
  2. ログインボタンを押下したら、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サーバーからのレスポンスをそのまま返します。
https://nextjs.org/docs/app/building-your-application/routing/route-handlers#cookies


  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が記載されています。
https://ja.react.dev/reference/react/useActionState

ただ、Next.jsの公式はuseFormStateのままなので、少し困惑してしまいました。。

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#server-side-form-validation


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の設定はレスポンスの値に合わせないと、正しい期限が反映されない可能性があるため注意が必要です。

https://nextjs.org/docs/app/api-reference/functions/cookies

  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.tsxloading.tsx)はクライアント側の状態を検知できないというデメリットもあります。

そのため、Server ActionsとRoute Handlerを組み合わせて使うと、Suspense機能のエラーバリデーションと従来のエラーバリデーションをそれぞれ別々に管理する必要が出てきます。

サスペンスはエフェクトやイベントハンドラ内でデータフェッチが行われた場合にはそれを検出しません。

https://ja.react.dev/reference/react/Suspense#usage

終わりに

別にAPIサーバーを用意しているケースでは、Route Handlerで統一した方が無難だなと思いました。
ただ、Route Handlerで統一してしまうと、Next.jsで使用できる機能に制限がかかるのは痛いですね。。
将来的にServer Actionsが他のHTTPメソッドにも対応すれば、実装がさらにスムーズになりそうです。
今回の検討を通じて、Next.jsは基本的にフルスタックで完結するように設計されていることを改めて実感しました。
今後のアップデートで、さらに柔軟な機能が増えることを期待しています!💫

Discussion

HikaruooHikaruoo

Echoサーバーからのトークン取得とCookie設定のフローや、Reactのバージョン変化によるuseFormStateからuseActionStateへの変更に対する戸惑いには、フルスタック開発ならではのリアルな悩みを感じました。