supabase와 NextJS로 블로그 만들기 2편
안녕하세요?
이번 시간에는 지난 시간에 만들었던 Supabase 블로그 만들기 2편입니다.
1편에서는 supabase에서 데이터를 불러와서 보여주는 페이지를 만들었는데요.
2편에서는 각 블로그의 상세 페이지에 대해 만들어 보겠습니다.
supabase의 posts 테이블은 일련번호가 id라는 이름으로 매겨지는데요.
이 id라는 것을 params으로 해서 PostDetailPage를 만들어 보겠습니다.
id를 a tag 링크에 집어넣어야 돼서 지난 시간의 posts.tsx 파일을 조금 수정하겠습니다.
const ListPosts = ({ post }: { post: PostsProps }) => {
....
....
....
<a
href={`/post/${post.id}`}
className="flex items-center w-full px-6 py-3 mb-3 text-lg text-white bg-indigo-500 rounded-md sm:mb-0 hover:bg-indigo-700 sm:w-auto"
>
...
...
...
...
위 코드를 보시면 어딜 고쳐야 되는지 알수 있을 겁니다.
본격적으로 상세 페이지를 만들기 전에 먼저, 한 가지 짚고 넘어가야 하는 게 있는데요.
supabase는 서버 사이드나 클라이언트 사이드 모두에서 DB에 접근할 수 있습니다.
그래서 오늘은 서버 사이드 접근법, 클라이언트 접근법 모두에 대해 알아보겠습니다.
1. 서버 사이드 접근
/post/[id].tsx
먼저, NextJS에서 다이내믹 라우팅을 하려면 위와 같은 파일이 필요합니다.
브라우저에서는 다음과 같은 방식으로 query가 전달됩니다.
https://localhost:3000/post/1
/posts 가 전체 리스트를 보여준다면 post/1 방식은 상세 페이지를 보여주는 형식인 거죠.
import { useEffect, useState } from "react";
import { GetServerSideProps, NextApiRequest } from "next";
import { useMessage } from "../../lib/message";
import { supabase } from "../../lib/supabase";
import { User } from "@supabase/supabase-js";
import classNames from "classnames";
import { PostsProps } from "../posts";
const PostDetailPage = ({ user, post }: { user: User; post: PostsProps }) => {
const { messages, handleMessage } = useMessage();
return (
<div className="flex flex-col items-center justify-start py-4 min-h-screen">
<h2 className="text-2xl my-4">Hello, Supabase User!</h2>
{messages &&
messages.map((message) => (
<div
className={classNames(
"shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center",
message.type === "error"
? "bg-red-500 text-white"
: message.type === "success"
? "bg-green-300 text-gray-800"
: "bg-gray-100 text-gray-800"
)}
>
{message.message}
</div>
))}
<h3 className="py-3 font-semibold text-lg text-blue-600">
Post Detail List
</h3>
{post && (
<section className="text-center lg:text-left">
{some code here}
</section>
)}
</div>
);
};
export default PostDetailPage;
export type NextAppPageUserPostProps = {
props: {
user: User;
loggedIn: boolean;
post: PostsProps;
};
};
export type NextAppPageRedirProps = {
redirect: {
destination: string;
permanent: boolean;
};
};
export type NextAppPageServerSideProps =
| NextAppPageUserPostProps
| NextAppPageRedirProps;
export const getServerSideProps: GetServerSideProps = async ({
req,
params,
}): Promise<NextAppPageServerSideProps> => {
const { user } = await supabase.auth.api.getUserByCookie(req);
if (!user) {
return {
redirect: {
destination: "/auth",
permanent: false,
},
};
}
// returned supabase data is basically array, so post is array type
// but .single() function will return only one object, that is not array type.
let { data: post, error } = await supabase
.from("posts")
.select("*")
.eq("id", Number(params?.id))
.limit(1)
.single();
if (error) {
console.log(error);
} else {
// console.log(post);
}
return {
props: {
user,
loggedIn: !!user,
post,
},
};
};
일단 Props를 먼저 살펴볼까 합니다.
export type NextAppPageUserPostProps = {
props: {
user: User;
loggedIn: boolean;
post: PostsProps;
};
};
상세 페이지는 위와 같은 props를 가지는데요.
PostsProps는 posts.tsx파일에서 import 했습니다.
그리고 NextJS의 getServerSideProps 함수에서 User 정보와 post 정보를 불러오는데요.
// returned supabase data is basically array, so post is array type
// but .single() function will return only one object, that is not array type.
let { data: post, error } = await supabase
.from("posts")
.select("*")
.eq("id", Number(params?.id))
.limit(1)
.single();
if (error) {
console.log(error);
} else {
// console.log(post);
}
supabase.from.select.eq.limit.single 방식으로 가져왔습니다.
eq 메서드는 equal 이란 뜻인데요.
"id"가 params.id 와 같은지 체크하는 방식입니다.
여기서 params.id에 Number로 캐스팅을 꼭 해줘야 합니다.
params.id는 string으로 전달되기 때문에 Number로 캐스팅해야 합니다.
그리고 limit.single 메서드를 썼는데요.
만약 single 메서드를 쓰지 않으면 supabase는 배열을 리턴합니다.
만약 배열이 리턴되면 props도 배열 방식으로 바꿔야 됩니다.
그런데 우리가 받는 거는 posts에서 한 개의 데이터기 때문에 single 메소드를 썼고 single 메서드는 그냥 한 개의 객체만 리턴합니다.
이제 getServerSideProps에서 user 정보와 post 정보를 전달하게 되면 우리가 만들 상세 페이지는 props에 getServerSideProps에서 제공하는 데이터를 가지게 됩니다.
const PostDetailPage = ({ user, post }: { user: User; post: PostsProps }) => {
위와 같이 PostDetailPage 가 받는 인수는 user, post인데요.
이제 PostDetailPage에서 post 변수를 그냥 자유롭게 쓸 수 있습니다.
이제 UI 부분을 만들어 볼까요?
const PostDetailPage = ({ user, post }: { user: User; post: PostsProps }) => {
const { messages, handleMessage } = useMessage();
const [date, setDate] = useState<string>();
useEffect(() => {
// null means that post is empty
if (post === null) {
console.log(post);
handleMessage({ message: "Error : No post", type: "error" });
} else {
let postDate = new Date(post.inserted_at);
setDate(postDate.toLocaleDateString("kr"));
}
}, []);
return (
<div className="flex flex-col items-center justify-start py-4 min-h-screen">
<h2 className="text-2xl my-4">Hello, Supabase User!</h2>
{messages &&
messages.map((message) => (
<div
className={classNames(
"shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center",
message.type === "error"
? "bg-red-500 text-white"
: message.type === "success"
? "bg-green-300 text-gray-800"
: "bg-gray-100 text-gray-800"
)}
>
{message.message}
</div>
))}
<h3 className="py-3 font-semibold text-lg text-blue-600">
Post Detail List
</h3>
{post && (
<section className="text-center lg:text-left">
<div className="max-w-screen-xl px-4 py-16 mx-auto sm:px-6 lg:px-8 lg:items-end lg:justify-between lg:flex">
<div className="max-w-xl mx-auto lg:ml-0">
<h1 className="text-sm font-medium tracking-widest text-indigo-600 uppercase">
{date}
</h1>
<h2 className="mt-2 text-3xl font-bold sm:text-4xl">
{post?.title}
</h2>
</div>
<p className="max-w-xs mx-auto mt-4 text-gray-700 lg:mt-0 lg:mr-0">
{post?.content}
</p>
</div>
</section>
)}
</div>
);
};
post 변수가 null 이면 데이터가 없다고 에러 메시지 처리해주고 있고,
그다음으로 date 부분을 한국 형식에 맞게 조정해 주고 있습니다.
테스트 결과를 볼까요?
날짜, 제목, 콘텐츠 모두 잘 표시되고 있습니다.
브라우저에서 id 부분을 3으로 변경해 볼까요?
id=3은 없기 때문에 PostDetailPage가 받은 인수중에 post에는 null이 전달됩니다.
그래서 useEffect 훅 부분에서 에러 메시지를 처리하고 있습니다.
그럼 클라이언트 사이드에서 처리하는 방법을 알아볼까요?
2. 클라이언트 사이드
import { useEffect, useState } from "react";
import { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { useMessage } from "../../lib/message";
import { supabase } from "../../lib/supabase";
import { User } from "@supabase/supabase-js";
import classNames from "classnames";
import { PostsProps } from "../posts";
const PostDetailPage = ({ user }: { user: User }) => {
const { messages, handleMessage } = useMessage();
return ()
};
export default PostDetailPage;
export type NextAppPageUserProps = {
props: {
user: User;
loggedIn: boolean;
};
};
export type NextAppPageRedirProps = {
redirect: {
destination: string;
permanent: boolean;
};
};
export type NextAppPageServerSideProps =
| NextAppPageUserProps
| NextAppPageRedirProps;
export const getServerSideProps: GetServerSideProps = async ({
req,
params,
}): Promise<NextAppPageServerSideProps> => {
const { user } = await supabase.auth.api.getUserByCookie(req);
if (!user) {
return {
redirect: {
destination: "/auth",
permanent: false,
},
};
}
return {
props: {
user,
loggedIn: !!user,
},
};
};
일단 getServerSideProps 함수에서는 user 정보를 얻고 만약 없으면 로그인하라고 redirect 하는 간단한 서버사이드 체크만 합니다.
그리고 getServerSideProps에서는 props 정보를 전혀 제공하지 않고 있습니다.
클라이언트 사이드에서는 리액트 컴포넌트 useEffect 훅에서 supabase에 접근하여 데이터를 가져와야 합니다.
코드를 같이 볼까요?
const PostDetailPage = ({ user }: { user: User }) => {
const { messages, handleMessage } = useMessage();
const [date, setDate] = useState<string>();
const [post, setPost] = useState<PostsProps>();
const router = useRouter();
const { id } = router.query;
useEffect(() => {
fetchPosts();
}, []);
const fetchPosts = async () => {
let { data: post, error } = await supabase
.from("posts")
.select("*")
.eq("id", Number(id))
.limit(1)
.single();
if (error) {
console.log(error);
handleMessage({ message: "Error : No post", type: "error" });
} else {
setPost(post);
let postDate = new Date(post.inserted_at);
setDate(postDate.toLocaleDateString("kr"));
}
};
서버 사이드 처리하는 방식과 비슷합니다.
다만, 클라이언트 사이드에서의 query는 nextjs의 useRouter를 사용합니다.
그리고 UI 부분은 서버 사이드 부분과 같습니다.
return (
<div className="flex flex-col items-center justify-start py-4 min-h-screen">
<h2 className="text-2xl my-4">Hello, Supabase User!</h2>
{messages &&
messages.map((message) => (
<div
className={classNames(
"shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center",
message.type === "error"
? "bg-red-500 text-white"
: message.type === "success"
? "bg-green-300 text-gray-800"
: "bg-gray-100 text-gray-800"
)}
>
{message.message}
</div>
))}
<h3 className="py-3 font-semibold text-lg text-blue-600">
Post Detail List
</h3>
{post && (
<section className="text-center lg:text-left">
<div className="max-w-screen-xl px-4 py-16 mx-auto sm:px-6 lg:px-8 lg:items-end lg:justify-between lg:flex">
<div className="max-w-xl mx-auto lg:ml-0">
<h1 className="text-sm font-medium tracking-widest text-indigo-600 uppercase">
{date}
</h1>
<h2 className="mt-2 text-3xl font-bold sm:text-4xl">
{post?.title}
</h2>
</div>
<p className="max-w-xs mx-auto mt-4 text-gray-700 lg:mt-0 lg:mr-0">
{post?.content}
</p>
</div>
</section>
)}
</div>
);
테스트 결과를 볼까요?
역시 실행이 잘 되고 있네요.
그리고 id=3으로 일부러 에러를 내 볼까요?
역시나 에러 처리도 잘 되고 있습니다.
자, 그럼 NextJS에서 서버 사이드에서 데이터를 처리할지, 클라이언트 사이드에서 데이터를 처리할지 고민하실 텐데요.
데이터의 양이 많을 경우를 가정해서 각각 테스트해보고 가장 빠른 처리 방식을 이용하시길 바랍니다.
그럼, 오늘은 이만 줄이겠습니다.