// lib/action.js
"use server";
import { redirect } from "next/navigation";
import { saveMeal } from "./meals";
function isInvalidText(text) {
return !text || text.trim() === "";
}
// shareMeal(prevState, formData)로 변경
export async function shareMeal(prevState, formData) {
const meal = {
title: formData.get("title"),
creator_email: formData.get("email"),
summary: formData.get("summary"),
image: formData.get("image"),
instructions: formData.get("instructions"),
creator: formData.get("name"),
};
if (
isInvalidText(meal.title) ||
isInvalidText(meal.summary) ||
isInvalidText(meal.instructions) ||
isInvalidText(meal.creator) ||
isInvalidText(meal.creator_email) ||
!meal.creator_email.instructions("@") ||
!meal.image ||
meal.image.size === 0
) {
// 직렬화 가능한 것으로 리턴.
return {
message: "Invalid input.",
};
}
await saveMeal(meal);
redirect("/meals");
}
// app/meals/share/page.js
("use client");
import { useFormState } from "react-dom"; // 추가
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
import MealsFormSubmit from "@/components/meals/meals-form-submit";
export default function ShareMealPage() {
const [state, formAction] = useFormState(shareMeal, { message: null }); // 추가
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={formAction}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker name="image" label="Your image" />
{/* 추가 */}
{state.message && <p>{state.message}</p>}
<p className={classes.actions}>
<MealsFormSubmit />
</p>
</form>
</main>
</>
);
}
useFormState
훅은 리액트의 useState
훅과 약간 비슷하게 동작한다.useFormState
훅은 Server Actions를 통해 제출될 form을 사용하는 페이지나 컴포넌트의 상태를 관리한다.useFormState(arg1, arg2)
useFormState
의 반환값은 useState
처럼 두개의 요소로 되어있다.npm run build
실행 → npm start
로 배포 서버 실행npm run build
를 실행하면 NextJS는 실제로 앱에서 사전 생성될 수 있는 모든 페이지를 모두 사전 렌더링하고 생성하여 기본적으로는 동적 웹페이지가 아니게 된다.NextJS는 모든 페이지를 사전 렌더링 함으로써 배포된 직후부터 모든 페이지가 동작할 수 있게 한다. → 웹 사이트에 처음 방문해도 렌더링을 기다릴 필요 없이 즉시 완성된 페이지를 볼 수 있다. (빌드 프로세스에서 모든 데이터를 불러오고 렌더링한다.)
revalidatePath('/meals')
: NextJS가 특정 path(경로)에 속하는 캐시의 유효성 재검사를 하게 한다. → 기본값으로 설정한 path만 검사한다. 중첩된 path는 재검사 하지 않는다.revalidatePath('/meals', 'page')
을 사용한다면 두번째 인수의 기본값은 page로 이 path의 해당 페이지만 재검사하겠다는 뜻이 된다.revalidatePath('/meals', 'layout')
을 사용한다면 두번째 인수의 기본값은 layout으로 재검사되는 것은 layout이다. → 중첩된 페이지를 포함하므로 중첩된 모든 페이지를 재검사 한다.만약 모든 페이지의 캐시를 재검사 하고싶다면... revalidatePath('/', 'layout')
'The requested resource isn't a valid image for /images/dune-part-two.jpg received text/html; charset=utf-8' 라는 오류가 발생하면서 작성한 새로운 데이터에 사진이 뜨지 않는다.
현재 public/images 폴더에 저장을 하고있다. 따라서 이미지가 보이지 않는다.
개발 환경에서는 해당 폴더에 접근이 가능하지만, 배포 환경에서는 .next 폴더에 복사가 되고 .next 폴더를 사용하게 된다.
🔗 Next.js | Static Assets in public
위의 사진 처럼 Vercel blob(혹은 AWS S3)을 사용하는 것을 추천하고 있다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:GetObjectVersion"],
"Resource": ["arn:aws:s3:::zoekangdev-nextjs-demo-users-image/*"]
}
]
}
image: '/images/burger.jpg'
→ image: 'burger.jpg
<Image
src={`https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/${image}`}
alt={title}
fill
/>
<Image
src={`https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/${meal.image}`}
alt={meal.title}
fill
/>
node initdb.js
실행하여 데이터베이스 업그레이드Invalid src prop (https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/burger.jpg) on
next/image
, hostname "zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com" is not configured under images in yournext.config.js
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host
<Image>
컴포넌트를 사용할 때 외부 URL을 허용하지 않기 때문에 발생하는 것이다.const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname:
"zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com",
port: "",
pathname: "/**",
},
],
},
};
remotePatternsconfig
을 통해 특정 S3 URL을 이미지의 유효한 소스로 사용 가능하게 되었다.유저가 생성한 이미지 데이터를 S3에 포워딩(forward). 이는 AWS에서 제공하는 패키지인 '@aws-sdk/client-s3'를 통해 가능하다.
설치 : npm install @aws-sdk/client-s3
lib/meals.js 편집
// 가장 상단에 추가
import { S3 } from "@aws-sdk/client-s3";
const s3 = new S3({
region: "ap-northeast-2",
});
saveMeal()
편집
export async function saveMeal(meal) {
meal.slug = slugify(meal.title, { lower: true });
meal.instructions = xss(meal.instructions); // instructions 검열
const extension = meal.image.name.split(".").pop(); // 마지막 요소. 즉 확장자 받음
const fileName = `${meal.slug}.${extension}`;
const bufferedImage = await meal.image.arrayBuffer(); // arrayBuffer함수가 프로미스를 반환 -> 버퍼로 변환됨.. 따라서 await 키워드 사용
s3.putObject({
Bucket: "zoekangdev-nextjs-demo-users-image",
Key: fileName,
Body: Buffer.from(bufferedImage),
ContentType: meal.image.type,
});
meal.image = fileName; // 모든 이미지에 관한 요청은 자동적으로 public 폴더로 보내짐
// 데이터베이스에 저장하기
db.prepare(
`
INSERT INTO meals
(title, summary, instructions, creator, creator_email, image, slug)
VALUES (
@title,
@summary,
@instructions,
@creator,
@creator_email,
@image,
@slug
)
`
).run(meal);
}
AWS_ACCESS_KEY_ID=<your aws access key>
AWS_SECRET_ACCESS_KEY=<your aws secret access key>
metadata
에서 메타데이터 필드를 지정할 수 있다. 🔗 참고metadata
상수는 다양한 메타데이터를 추가할 수 있게 해준다. ex. 검색 엔진 크롤러에 노출될 수 있게 하거나 페이지 링크를 X(트위터)나 페이스북에 공유할 때 보여준다.
- layout에 작성한 메타데이터를 해당 데이터가 감싸고있는 모든 페이지에 자동으로 적용된다.
- 만약 페이지에
metadata
가 존재한다면 페이지의metadata
가 우선 적용된다.
// app/meals/page.js
export const metadata = {
title: "All Meals",
description: "Browse the delicious meals shared by our vibrant community.",
};
metadata
라는 이름의 변수(상수)를 export 하는게 아니라 generateMetadata
라는 async 함수를 export 하여 메타데이터를 적용시킨다.// app/meals/[mealSlug]/page.js
export async function generateMetadata({ params }) {
const meal = getMeal(params.mealSlug);
if (!meal) {
notFound();
}
return {
title: meal.title,
description: meal.summary,
};
}