Shu Digital Works

Articles

投稿記事

Top > Articles > プログラミング > Next.js × Rails × React Hook Form × Zod で画像付きフォームを実装する

Next.js × Rails × React Hook Form × Zod で画像付きフォームを実装する

プログラミング Next.js Rails RHF Zod フォーム
公開日 更新日
Next.js × Rails × React Hook Form × Zod で画像付きフォームを実装する

画像付きの商品登録フォームを作るとき、
最初は useState と onChange ハンドラー、
自作のバリデーションロジックでも十分構築できます。

ただ、フォーム項目が増えたり、
バリデーションの条件が複雑になってくると、
状態管理やエラーメッセージの扱いが面倒になりがちです。

そこで今回は、Next.js × React Hook Form × Zod を使って、
画像アップロードに対応した商品登録フォームを実装しながら、
フォーム構築の負担を軽減する方法を整理してみました。

バックエンドには Rails を使用しており、
Next.js の API 経由で multipart/form-data による画像送信を行います。

リポジトリはこちら
https://github.com/shu915/digital_ichiba_frontend
https://github.com/shu915/digital_ichiba_backend

商品の新規登録ページ

ファイルは、src/app/dashboard/shop/product/new/page.tsx
このディレクトリ階層とページは、
Next.jsのApp Routerで、
ログインしたショップオーナーが、
商品を新規登録するためのページです。

import { Button } from "@/components/ui/button";
import Link from "next/link";
import { requireAuth } from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";
import PageTitle from "@/components/atoms/PageTitle";
import ShopProductsNewForm from "./ShopProductsNewForm";

export default async function ShopProductsNew() {
  await requireAuth();
  await requireShopOrAdmin();

  return (
    <div className="py-8 inner">
      <PageTitle title="商品新規登録" />
      <div className="flex justify-end mt-4 gap-2">
        <Button asChild>
          <Link href="/dashboard/shop">
            <span className="font-bold">ショップダッシュボードへ</span>
          </Link>
        </Button>
      </div>
      <ShopProductsNewForm />
    </div>
  );
}

requireAuth()でログインしている必要があり、
requireShopOrAdmin()で、ロールがショップか管理人である必要があります。
またショップのダッシュボードに飛ぶリンクがあります。
今回の本題は、ShopProductsNewFormなので、
それ以外ところは割愛します。

商品の新規登録フォーム

ファイルは、src/app/dashboard/shop/product/new/ShopProductsNewForm.tsx
先ほどのページからこのコンポーネントを呼び出しています。

"use client";
import { useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";

import { imageFileRequired } from "@/lib/formImageSchema";

export default function ShopProductsNewForm() {
  const schema = z.object({
    product_name: z
      .string()
      .min(1, "名前は必須です")
      .max(120, "120文字以内で入力してください"),
    product_description: z.string().max(500, "500文字以内で入力してください"),
    product_price: z
      .number({ message: "整数で入力してください" })
      .int({ message: "整数で入力してください" })
      .min(0, "0以上の整数で入力してください"),
    product_stock: z
      .number({ message: "整数で入力してください" })
      .int({ message: "整数で入力してください" })
      .min(0, "0以上の整数で入力してください"),
    product_image: imageFileRequired,
  });

  type FormValues = z.infer<typeof schema>;

  const formRef = useRef<HTMLFormElement>(null);

  const {
    control,
    handleSubmit,
    register,
    reset,
    resetField,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      product_name: "",
      product_description: "",
      product_price: 0,
      product_stock: 0,
    },
  });

  const onSubmit = async (data: FormValues) => {
    const formData = new FormData();
    formData.append("product[name]", data.product_name);
    formData.append("product[description]", data.product_description);
    formData.append("product[price]", String(data.product_price));
    formData.append("product[stock]", String(data.product_stock));
    formData.append("product[image]", data.product_image);

    const res = await fetch("/api/products", {
      method: "POST",
      body: formData,
      cache: "no-store",
    });

    if (res.ok) {
      reset({
        product_name: "",
        product_description: "",
        product_price: 0,
        product_stock: 0,
      });
      resetField("product_image");
      formRef.current?.reset();
      toast("商品を作成しました");
    } else {
      toast("商品の作成に失敗しました");
    }
  };

  return (
    <form
      ref={formRef}
      onSubmit={handleSubmit(onSubmit)}
      className="w-3xl mx-auto p-4 flex flex-col gap-6"
    >
      <div className="flex flex-col gap-1">
        <label htmlFor="product_name" className="font-bold">
          名前(必須)
        </label>
        {errors.product_name && (
          <p className="text-red-500 text-sm">{errors.product_name.message}</p>
        )}
        <Input
          id="product_name"
          placeholder="商品名"
          {...register("product_name")}
        />
      </div>

      <div className="flex flex-col gap-1">
        <label htmlFor="product_description" className="font-bold">
          説明
        </label>
        {errors.product_description && (
          <p className="text-red-500 text-sm">
            {errors.product_description.message}
          </p>
        )}
        <Textarea
          id="product_description"
          placeholder="500文字以内で入力"
          className="resize-none h-32"
          {...register("product_description")}
        />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div className="flex flex-col gap-1">
          <label htmlFor="product_price" className="font-bold">
            税抜き価格
          </label>
          {errors.product_price && (
            <p className="text-red-500 text-sm">
              {errors.product_price.message}
            </p>
          )}
          <Input
            id="product_price"
            type="number"
            min={0}
            step="1"
            inputMode="numeric"
            placeholder="0"
            {...register("product_price", { valueAsNumber: true })}
          />
        </div>
        <div className="flex flex-col gap-1">
          <label htmlFor="product_stock" className="font-bold">
            在庫
          </label>
          {errors.product_stock && (
            <p className="text-red-500 text-sm">
              {errors.product_stock.message}
            </p>
          )}
          <Input
            id="product_stock"
            type="number"
            min={0}
            step="1"
            inputMode="numeric"
            placeholder="0"
            {...register("product_stock", { valueAsNumber: true })}
          />
        </div>
      </div>

      <div className="flex flex-col gap-1">
        <label htmlFor="product_image" className="font-bold">
          画像(必須)
        </label>
        {errors.product_image && (
          <p className="text-red-500 text-sm">
            {errors.product_image.message as string}
          </p>
        )}
        <Controller
          name="product_image"
          control={control}
          render={({ field }) => (
            <Input
              id="product_image"
              type="file"
              accept="image/*"
              onChange={(e) => field.onChange(e.target.files?.[0])}
            />
          )}
        />
      </div>

      <Button
        type="submit"
        disabled={isSubmitting}
        className="w-48 mx-auto max-w-full font-bold"
      >
        作成する
      </Button>
    </form>
  );
}

このフォームは、商品登録フォームで、
名前、説明文、税抜き価格、在庫数、画像を登録します。
画像は独自のzのカスタムスキーマを作ります。
全体像を掴むことが大事なので、
先にフォーム全体の解説をしてから、
画像のzのカスタムスキーマの解説をします。

"use client";

このコンポーネントは、
クライアントサイドで、値を動的に扱うので、
クライアントコンポーネントにします。

import { useRef } from "react";

商品の新規登録したあと、
ポップアップで成功を知らせています。
ページ遷移がないので、
useRefを使ってフォームを空っぽにすることで、
次の商品を挿入できるようにします。

import { useForm, Controller } from "react-hook-form";

useForm はフォーム全体の状態管理、
Controller は画像ファイルのように register では扱いにくい入力項目を連携させるために使います。

import { z } from "zod";

z は Zod ライブラリのオブジェクトで、
フォーム入力のバリデーションルール(スキーマ)を定義するために使います。

import { zodResolver } from "@hookform/resolvers/zod";

zodResolver は、Zod のバリデーションを React Hook Form に統合するための関数です。

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";

shadcn-uiのUIコンポーネントをインポートしています。
ボタンとインプットとテキストエリアをインポートしています。
toastというのは、送信成功時などに出す、ポップアップです。

import { imageFileRequired } from "@/lib/formImageSchema";

これは画像ファイル用の自作のZodカスタムバリデーションスキーマです。
全体像の解説を優先するため、後で詳しく解説します。

export default function ShopProductsNewForm() {

デフォルト関数として定義しています。

const schema = z.object({

z.object()は、オブジェクトを引数に取ることで、バリデーションのルールを定義します。

product_name: z
  .string()
  .min(1, "名前は必須です")
  .max(120, "120文字以内で入力してください"),

product_nameをオブジェクトのキーとして定義しています。
このキーは React Hook Form 側でも register(“product_name”) や
Controller の name プロパティで使うため、
ここで定義したものと一致させておく必要があります。

z.string().min().max(),
これは文字列であることを制限し、
最小文字数と最大文字数を指定しています。
min()は第一引数に最小文字数を入れて、
第二引数にバリデーションエラーのメッセージを入れます。
max()も同じように、第一引数に最大文字数を入れて、
第二引数にバリデーションエラーのメッセージを入れます。

product_description: z.string().max(500, "500文字以内で入力してください"),

商品の説明文は省略可能なので、min()がありません。
ほかは同じです。

product_price: z
  .number({ message: "整数で入力してください" })
  .int({ message: "整数で入力してください" })
  .min(0, "0以上の整数で入力してください"),

これは税抜き価格なので、
number()で数字であることを、
int()で整数であることを、
min()で0以上の整数であることを指定しています。
0以上とすることで、無料のものも置くことができます。

product_stock: z
  .number({ message: "整数で入力してください" })
  .int({ message: "整数で入力してください" })
  .min(0, "0以上の整数で入力してください"),

これは在庫数なので、
number()で数字であることを、
int()で整数であることを、
min()で0以上の整数であることを指定しています。
在庫を0以上とすることで、在庫が0でも登録できるようにしています。

product_image: imageFileRequired,

これは画像ファイルを扱うバリデーションのスキーマです。
別のファイルでz.customで定義したカスタムスキーマを値として渡しています。
詳細は後で説明します。

 });

これでz.objectを閉じます。

type FormValues = z.infer<typeof schema>;

Zodで定義したバリデーションスキーマ(schema)から、
自動的に型情報を抽出して FormValues という型として定義しています。

typeof schemaでschemaの型を取得して、
Schemaを定義して、に代入してみると

type Schema = z.ZodObject<{
    product_name: z.ZodString;
    product_description: z.ZodString;
    product_price: z.ZodNumber;
    product_stock: z.ZodNumber;
    product_image: z.ZodCustom<File, File>;
}, z.core.$strip>

schema の型は上記のになっており、ZodObjectであるため、
そのまま使うことはできません。

z.infer<…> を使うことで、
Zodが保持しているバリデーション定義から、
実際の入力データの型を抽出できます。

その型を FormValues という名前で定義しています。

type FormValues = {
  product_name: string;
  product_description: string;
  product_price: number;
  product_stock: number;
  product_image: File;
}

型はこのようになっていて、
z.objectが書き換えられると、こちらも変わります。

const formRef = useRef<HTMLFormElement>(null);

useRefにTSがあらかじめ用意しているHTMLFormElementの型を渡します。
これで、このuseRefが扱うものをフォームのエレメントに制限します。
初期値はnullで、formRefという名前で定義します。
これはあとで、送信成功したときに、
フォームを空にするのに使います。


  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      product_name: "",
      product_description: "",
      product_price: 0,
      product_stock: 0,
    },
  });

先にuseFormと引き取るものを見ていきます。
useForm は、
フォーム全体の状態(入力値、エラー、送信中フラグなど)を
まとめて管理するReact Hook Formのメインフックです。
Reactでフォームを扱う際の「状態管理」「バリデーション」「送信処理」を1つに統合できます。

型引数で先ほどのFormValuesを引き取ります。
引数はオブジェクトで引き取ります。

resolver に zodResolver(schema) を指定することで、
Zodのスキーマを使った型安全なバリデーションが可能になります。
フォーム送信時にはこのスキーマに従って入力チェックが自動で行われ、
エラーは errors に格納されます。

defaultValues はフォームの初期状態を定義します。
ここで設定した値は、reset()を呼び出すとリセット後に再び反映されます。

const {
    control,
    handleSubmit,
    register,
    reset,
    resetField,
    formState: { errors, isSubmitting },

次に、useFormの返すものを見ていきます。

control
Controllerを使うとき、controlをControllerにわたします。
Controllerは画像ファイルを扱うのに使います。

handleSubmit
<form onSubmit={handleSubmit(onSubmit)}>
とすることで、バリデーションを付与します。

register
inputやtextareaを登録するのに使います。

reset
フォームの値をリセットするのに使います

resetField
画像のアップロードをリセットするのに使います。

formState: { errors, isSubmitting },
formStateは様々の状態を管理しており、
errorsとisSubmittingを出しておきます。

errorsの中にエラーやエラーメッセージが格納されます。

isSubmittingは、フォーム送信中にtrueになるので、
その間送信ボタンを押せないようにするのに使います。

const onSubmit = async (data: FormValues) => {

送信用の関数を定義しています。
dataはFormValuesを型に取ります。
dataはオブジェクトであり、
フォームのname属性やregisterで登録した名前がキーになり、
ユーザーが入れた値が値になり、
FormValuesと型が一致する必要があります。

const formData = new FormData();
formData.append("product[name]", data.product_name);
formData.append("product[description]", data.product_description);
formData.append("product[price]", String(data.product_price));
formData.append("product[stock]", String(data.product_stock));
formData.append("product[image]", data.product_image);

ファイルがなければjsonでいいのですが、
ファイルがあるのでFormDataを使ってAPI通信します。
new FormData()でインスタンスを作って、formDataに入れます。

appendの第一引数は、バックエンドに送るためのキーで、
第二引数が送りたい値です。

def create_product_params
  params.require(:product).permit(:name, :description, :price, :stock, :image)
end

キーを”product[name]”のように記述しているのは
Railsで上記のように受け取るためです。
バリューはdata.product_nameのようにセットします。
税抜き価格と在庫数は送信するため1回文字列に変換します。

const res = await fetch("/api/products", {
  method: "POST",
  body: formData,
  cache: "no-store",
});

クライアントコンポーネントの中から、
Next.jsのバックエンドのproxyにfetchしているので、
相対パスです。
proxyの詳細解説はこちら
メソッドはPOSTで、
bodyに先ほど作ったformDataを入れます。
キャッシュはしません。
返り値をresに代入します。

if (res.ok) {
  reset({
    product_name: "",
    product_description: "",
    product_price: 0,
    product_stock: 0,
  });
  resetField("product_image");
  formRef.current?.reset();
  toast("商品を作成しました");
} else {
  toast("商品の作成に失敗しました");
}

res.okならば
resetを使ってフォームの値をリセットします。
画像ファイルはresetFieldでリセットします。
これで仮想DOMの値はリセットされましたが、
このままではブラウザ上のフォーム(実際の入力欄)には以前の値が残っています。
formRef.current?.reset();でユーザーが見ている値もリセットさせます。
formRef.current には <form> 要素が入っているので、
これは実質 form.reset() を実行しているのと同じです。



return (
  <form
    ref={formRef}
    onSubmit={handleSubmit(onSubmit)}
    className="w-3xl mx-auto p-4 flex flex-col gap-6"
  >

ここからはレンダリングするJSXの中です。
CSSの解説は割愛します。

フォームタグを書きます。
ref={formRef}で、
formRefにこのフォーム要素が代入されます。

onSubmit={handleSubmit(onSubmit)}
このように書くことで、フォーム送信時に handleSubmit がまず実行され、
Zod で定義したバリデーションを通過した場合のみ、
自分が定義したonSubmit 関数が呼び出されます。

<div className="flex flex-col gap-1">
  <label htmlFor="product_name" className="font-bold">
    名前(必須)
  </label>
  {errors.product_name && (
    <p className="text-red-500 text-sm">{errors.product_name.message}</p>
  )}
  <Input
    id="product_name"
    placeholder="商品名"
    {...register("product_name")}
  />
</div>

ここは商品名を入れるところなので
Inputでidを設定してlabelでクリックできるようにして、
プレースホルダーを設定します。

ポイントは{…register(“product_name”)}と書くことです。
register()に引数で、”product_name”を渡すことで、
product_nameに関する、
input要素に渡す属性名と属性値がオブジェクトで返ってくるので、
それをスプレッド構文で展開しています。

errors.product_nameがtrueの場合は
errors.product_name.messageを出力させて、
ユーザーに知らせます。

説明のラベルとフィールドはほとんど同じなので省略します。
Inputの代わりにTextareaになっていること以外は、
商品名と同じです。

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  <div className="flex flex-col gap-1">
    <label htmlFor="product_price" className="font-bold">
      税抜き価格
    </label>
    {errors.product_price && (
      <p className="text-red-500 text-sm">
        {errors.product_price.message}
      </p>
    )}
    <Input
      id="product_price"
      type="number"
      min={0}
      step="1"
      inputMode="numeric"
      placeholder="0"
      {...register("product_price", { valueAsNumber: true })}
    />
  </div>

これは税抜き価格です。
違いがあるところだけを説明します。
Inputはtype=”number”とすることで、数字を強制します。
min={0}とすることで、マイナスが入らないようにします。
step=”1″とすることで、1刻みに増減します。
inputMode=”numeric”で、モバイル端末で数字キーボードがでます。

{…register(“product_price”, { valueAsNumber: true })}
この第二引数のオプションを渡すことで、
ユーザーが入力したものを数字として扱うようになります。

在庫の構造は税抜き価格とは同じなので省略します。

<div className="flex flex-col gap-1">
  <label htmlFor="product_image" className="font-bold">
    画像(必須)
  </label>
  {errors.product_image && (
    <p className="text-red-500 text-sm">
      {errors.product_image.message as string}
    </p>
  )}
  <Controller
    name="product_image"
    control={control}
    render={({ field }) => (
      <Input
        id="product_image"
        type="file"
        accept="image/*"
        onChange={(e) => field.onChange(e.target.files?.[0])}
      />
    )}
  />
</div>

ここでは画像ファイルアップロードを扱っています。
Controllerはregisterで扱えない、ファイル、セレクト、日付などを扱うのに使います。

control={control}

control={control}でuseFormの返り値のcontrolをわたします。

render={({ field }) => (

renderのところは少し複雑です。
({ field })のところの()はアロー関数の引数を入れるカッコで
{ filed }というのはControllerからオブジェクトが渡ってきて、
その中のキーがfiledのものの値を分割代入しています。

(<Input />)となっているので、これはJSXとして<Input />を返しています。
type=”file”で、ファイルを指定します。
accept=”image/*”で拡張子をpng、jpeg、webpなどに制限します。

onChange={(e) => field.onChange(e.target.files?.[0])}

ファイルが選択されると onChange が発火し、
field.onChange に選択したファイルを渡すことで、
React Hook Form に値が反映されます。

このようにControllerを使って、内部でInputを返すことで、
ファイルアップロードを可能にできます。

<Button
  type="submit"
  disabled={isSubmitting}
  className="w-48 mx-auto max-w-full font-bold"
>
  作成する
</Button>

送信ボタンです。
既に送信中の場合はdisabled={isSubmitting}で連打できないようになっています。

画像ファイル用のZodカスタムスキーマ

"use client";
import { z } from "zod";

const isFile = (v: unknown): v is File =>
  typeof File !== "undefined" && v instanceof File; // SSRでも安全

const ACCEPTED_IMAGE_TYPES: string[] = [
  "image/png",
  "image/jpeg",
  "image/webp",
];

const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB

export const imageFileRequired = z
  .custom<File>((f) => isFile(f), { message: "画像ファイルを選択してください" })
  .refine(
    (f) => f.size <= MAX_IMAGE_BYTES,
    "ファイルサイズは5MB以下にしてください"
  )
  .refine(
    (f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
    "対応している形式は PNG / JPG / WEBP のみです"
  );

export const imageFileOptional = imageFileRequired.optional();

lib/formImageSchema.tsにこのファイルを作ります。
全体像を優先するために、後で説明しますと先延ばしにしていた、
Zodのカスタムスキーマを解説します。

"use client";
import { z } from "zod";

クライアントコンポーネントを宣言します。
zをzodからインポートします。

const isFile = (v: unknown): v is File =>
  typeof File !== "undefined" && v instanceof File; // SSRでも安全

アロー関数を定義してisFileに代入しています。
処理部の{}とreturnは省略しています。
引数はvとして引き取ります。
unknown型にすることで、形が不明な値を安全に扱うことができます。
返り値はv is Fileとなっていて、
Fileであればtrueになります。

typeof File !== “undefined”というのは、
サーバーサイドのjsにはFileクラスがないからです。
instanceof Fileをサーバーサイドで実行するとエラーになるため、
ブラウザ環境であることを確認してから、
後ろのv instanceof Fileを実行しています。

v instanceof FileでvがFileクラスのインスタンスであることを確認します。

つまり、この関数の引数にファイルを入れると
ブラウザ環境であることを確認してから、
Fileクラスのインスタンスであれば、
アップロードしたものはファイルなので、
trueを返す関数を作っています。

const ACCEPTED_IMAGE_TYPES: string[] = [
  "image/png",
  "image/jpeg",
  "image/webp",
];

配列で許可する、画像タイプのMIMEを定義しています。
jpgは”image/jpeg”に含まれます。

const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB

サイズを5MBまでに制限しています。

export const imageFileRequired = z
  .custom<File>((f) => isFile(f), { message: "画像ファイルを選択してください" })
  .refine(
    (f) => f.size <= MAX_IMAGE_BYTES,
    "ファイルサイズは5MB以下にしてください"
  )
  .refine(
    (f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
    "対応している形式は PNG / JPG / WEBP のみです"
  );

imageFileRequiredとして定義します。
画像ファイルのアップロードを必須とします。

z.custom<File>((f) => isFile(f), { message: "画像を選択してください" })

これはメソッドチェーンの最初のcustomに対して型引数を渡しています。
Fileを渡すことでFileクラスのインスタンスであることを制限します。

第一引数で、
引き取ったファイルをfとして、isFileに渡して、
ファイルと確認したらtrueを返します。
第二引数は、エラーになったときのメッセージです。

.refine(
    (f) => f.size <= MAX_IMAGE_BYTES,
    "ファイルサイズは5MB以下にしてください"
  )

refine()でバリデーションに条件を追加できます。
第一引数はアロー関数で、=>はアロー関数のアローで、
右にある<=は矢印ではなく
MAX_IMAGE_BYTES以下(つまり5MB以下)という意味です。
第二引数はエラーメッセージです。

.refine(
    (f) => ACCEPTED_IMAGE_TYPES.includes(f.type),
    "対応している形式は PNG / JPG / WEBP のみです"
  );

f.typeでMIMEが取得できます。
ACCEPTED_IMAGE_TYPESがMIMEの入った配列なので、
その中に含まれていればtrueを返します。

export const imageFileOptional = imageFileRequired.optional();

これは今回は使いませんが、
.optional()をつけると選択制になります。
画像アップロードが任意のときに使います。

import { imageFileRequired } from "@/lib/formImageSchema";

//略

const schema = z.object({
  //略
  product_image: imageFileRequired,
});

実際使っている場面で、
インポートしたあと、
このようにz.object({})の中で、
スキーマを渡しています。

まとめ

Next.js × React Hook Form × Zod を使うことで、
型安全で扱いやすい画像アップロードフォームを構築できました。

  • React Hook Form:フォームの状態管理と送信処理を一元化
  • Zod:型定義とバリデーションを統合
  • Controller:ファイル入力のような特殊項目を柔軟に扱える
  • z.custom()+refine():ファイルサイズや形式を安全に検証
  • FormData:画像を含むデータを簡潔に送信

この構成を押さえておけば、
画像付きフォームの実装はスムーズに拡張できます。

関連記事

2025年4月の学習の振り返り

2025年4月の学習の振り返り

プログラミング

4月は、AWS、自動デプロイ、Terraformなどのスキルを学習。インフラの理解が深まる。

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

プログラミング

Next.jsとRails間のAPI通信をBFFで共通化。JWTやCookie制御も含めた実装を詳しく解説。

Next.js × Rails × Auth.js でGoogle OAuth + JWTによる認証を実装する

Next.js × Rails × Auth.js でGoogle OAuth + JWTによる認証を実装する

プログラミング

Next.js × Rails 構成で Google OAuth 認証を Auth.js + JWT で実装。Rails側の検証まで対応。