NextJS Page router로 3년 가까이 작업을 했다가 작년인 2023년부터 App router 안정화 버전을 출시하면서 공부를 시작했다.
하지만 2023년 출시 직후의 앱 라우터는 불완전하다고 생각이 들었다. 킬러피처라고 생각했던 서버액션 조차 Experimental 기능이었기 때문에 그 이후로 관심을 두지 않았다.
그러다가 최근에 간단한 프로젝트를 진행할 일이 있었는데 SEO가 상당히 중요하기도 서버 액션도 실험기능이 아닌 정식으로 지원한다고 해서 토이 프로젝트는 App router로 진행해보기로 했다.
사실 앱 라우터 설계 자체가 예전 PHP나 Ruby on Rails로 작업하는 것과 유사해서 결국 유행은 돌고 돈다는 말이 생각이 들었다.
작업하는데 크게 걸림돌은 없었지만 클라이언트 사이드 폼 Validation과 서버액션을 통합하는 부분에서 많은 삽질을 했다. 구글링을 해봐도 레퍼런스도 많이 없어서 더 고생한 것 같다.
그래서 나처럼 고생하고 있을 분들을 위해 React-hook-form을 이용한 클라이언트 사이드 폼 Validation과 서버액션을 통합한 간단한 코드 스니펫을 공유하고자 한다.
zod + react-hook-form 을 이용한 클라이언트 Validation을 진행하며, 같은 zod 스키마로 서버에서 한번 더 폼 데이터 Validation 을 진행한다.
schema.ts
import { z } from "zod";
export const postSchema = z.object({
title: z
.string()
.min(3, "제목은 최소 3글자 이상 입력해주세요.")
.max(100, "제목 글자 수가 너무 많습니다."),
content: z.string(),
});
page.tsx
import NewPostForm from "./(components)/form";
import { createPost } from "../../../actions";
export default async function NewPost({
params,
}: {
params: Promise<{ category: string }>;
}) {
const category = (await params).category;
const action = createPost.bind(null, category); // 서버액션 함수를 bind 하여 카테고리를 서버액션 인자로 보냄.
// 참고 문서: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#passing-additional-arguments
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<h1>새 게시글 작성</h1>
<NewPostForm category={category} action={action} />
</div>
);
}
form.tsx
useForm 훅을 사용해야 하기 때문에 클라이언트 컴포넌트 (use client)인 점 유의
"use client";
import React, { useActionState, useEffect } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormState } from "../../../../actions";
import { postSchema } from "../schema";
type PostFormData = z.infer<typeof postSchema>;
const NewPostForm: React.FC<{
category: string;
action: (previousState: FormState, data: FormData) => Promise<FormState>;
}> = ({ action }) => {
const [state, formAction] = useActionState(action, {
field: "",
message: "",
});
const { register, formState, setError } = useForm<PostFormData>({
resolver: zodResolver(postSchema),
defaultValues: { title: "", content: "" },
mode: "all", // handleSubmit을 사용하지 않을 것이므로 all로 설정하여 Validation 진행
});
// Form 제출후 오류객체가 리턴되면 form에 에러 수동등록
useEffect(() => {
if (state.field && state.message) {
setError(
state.field as never,
{ message: state.message },
{ shouldFocus: true }
);
}
}, [state, setError]);
return (
<form action={formAction}>
{formState.errors.root && (
<p aria-live="polite">{formState.errors.root.message}</p>
)}
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
placeholder="Enter the post title"
{...register("title")}
/>
{formState.errors.title && (
<p>{formState.errors.title.message}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
placeholder="Write your content here..."
rows={5}
{...register("content")}
></textarea>
{formState.errors.content && (
<p>{formState.errors.content.message}</p>
)}
</div>
<div>
<button
type="submit"
disabled={formState.isSubmitting} // !formState.isValid 로 추가 검증가능 (다만 그럴경우 자바스크립트 Disable시 폼 제출이 불가능해서 사용안했음)
>
{formState.isSubmitting ? "Submitting..." : "Submit"}
</button>
</div>
</form>
);
};
export default NewPostForm;
actions.ts
"use server";
import { prisma } from "@/prisma";
import { redirect } from "next/navigation";
import { postSchema } from "./posts/[category]/new/schema";
import { auth } from "../auth";
export type FormState = { field: string; message: string };
export async function createPost(
category: string,
data: FormData
): Promise<FormState> {
// 서버 사이드 폼 Validation
const formData = Object.fromEntries(data);
const parsed = postSchema.safeParse(formData);
if (!parsed.success) {
return {
field: "root",
message: "제목과 내용을 정상적으로 작성해주세요.",
};
}
const title = parsed.data.title;
const content = parsed.data.content;
if (!title) {
return { field: "title", message: "제목을 입력하세요." };
} else if (!content) {
return { field: "content", message: "내용을 입력하세요." };
}
// 비회원일 경우 로그인페이지로 redirect
const session = await auth();
if (!session?.user?.id) {
redirect("/api/auth/signin");
}
// DB 저장
const userId = session.user.id;
const slug = await generateUniqueSlug(title);
await prisma.post.create({
data: {
slug,
category: { connect: { urlParameter: category } },
title,
content,
user: { connect: { id: userId } },
},
});
// 생성된 게시글로 리다이렉트
redirect(`/posts/${category}/${encodeURIComponent(slug)}`);
}