게시판을 구성하기 위해서는 다음과 같은 4가지 화면이 필요합니다.
각 화면을 구현하고 Supabase를 연동하여 실제 데이터베이스와 연결되는 기본적인 CRUD기능을 연습해보겠습니다
먼저 게시글을 새로 작성할 수 있는 화면을 만들었습니다.
title과 content를 입력받아 Supabase의 posts 테이블에 데이터를 저장합니다.
// pages/create.vue
<template>
<div>
<h1>새 글 작성</h1>
<form @submit.prevent="submit">
<div>
<label for="title">제목</label>
<input id="title" v-model="title" required />
</div>
<div>
<label for="content">내용</label>
<textarea id="content" v-model="content" required></textarea>
</div>
<button type="submit">저장</button>
</form>
</div>
</template>
<script setup>
const supabase = useSupabase();
const router = useRouter();
const title = ref('');
const content = ref('');
async function submit() {
// posts 테이블에 제목, 내용 새 레코드 삽입
const { error } = await supabase
.from('posts')
.insert([{ title: title.value, content: content.value }]);
if (error) {
alert('저장 중 오류가 발생했습니다.');
console.error(error);
return;
}
}
</script>
제목, 내용을 입력하고 저장버튼을 누르면

supabase에 DB에 잘 들어와 있는것을 확인할 수 있습니당

게시글을 저장하면 메인 리스트로 이동하도록 submit 함수에 router를 추가합니다.
나중에는 '저장되었습니다!' 같은 popup을 추가해야겠어요. 지금은 갈길이 머니 일단 생략..
// pages/create.vue
async function submit() {
const { error } = await supabase
.from('posts')
.insert([{ title: title.value, content: content.value }]);
if (error) {
alert('저장 중 오류가 발생했습니다.');
console.error(error);
return;
}
router.push('/'); // 저장 후 목록(index)으로 이동
}
저장된 DB를 불러와서 화면에 뿌려줍니다.
게시판 가장 기본 형태인 테이블 구조로 마크업 하겠습니다.
// pages/index.vue
<template>
<div class="index">
<h1>게시글 목록</h1>
<table>
<thead>
<tr>
<th>No.</th>
<th>제목</th>
<th>작성일</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td>{{ post.created_at }}</td>
</tr>
</tbody>
</table>
<NuxtLink to="/create" class="btn">글쓰기</NuxtLink>
</div>
</template>
<script setup>
const supabase = useSupabase();
const { data: posts } = await useAsyncData('posts', async () => {
// posts 테이블에서 모든 컬럼을 선택한 후, id 컬럼을 기준으로 내림차순 정렬
const { data, error } = await supabase
.from('posts')
.select('*')
.order('id', { ascending: false });
if (error) {
console.error('Supabase fetch error:', error);
return [];
}
return data;
});
</script>

작성일 텍스트가 불필요하게 긴 이유는, DB 테이블을 만들 때 작성일 데이터타입을 timestamptz으로 선택했기 때문.... date타입으로 변경해주면 yyyy-mm-dd만 딱 출력됩니다!
// pages/create.vue
const router = useRouter();
async function submit() {
const { error } = await supabase
.from('posts')
.insert([
{
title: title.value,
content: content.value,
created_at: new Date().toISOString().split('T')[0], // 명시적으로 날짜 기본값 전달
},
]);
if (error) {
alert('저장 중 오류가 발생했습니다.');
console.error(error);
return;
}
router.push('/');
}

이제 리스트를 클릭했을 때, 해당 게시글의 상세페이지로 이동해야합니다. <tr>을 클릭했을 때 페이지 이동하도록 click event를 추가하겠습니다.
Nuxt에서는 [id].vue와 같이 대괄호로 파일명을 감싸면 URL 파라미터를 자동으로 처리합니다. (Dynamic Routes)
예: pages/post-[id].vue → /post-1, /post-14 등 경로에 자동 대응
// pages/index.vue
<tbody>
<tr v-for="post in posts" :key="post.id" @click="linkToPost(post.id)">
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td class="date">{{ post.created_at }}</td>
</tr>
</tbody>
<script setup>
function linkToPost(id) {
router.push(`/post-${id}`);
}
</script>
posts 테이블에서 조회한 데이터를 post.value에 할당하고 title, content를 화면에 뿌려줍니다
// pages/post-[id]/index.vue
<template>
<h1>{{ post.title }}</h1>
<p class="cont">{{ post.content }}</p>
<NuxtLink to="/" class="btn">목록으로</NuxtLink>
</template>
<script setup>
const supabase = useSupabase();
const post = ref(null);
onMounted(async () => {
// posts 테이블에서 id가 route.params.id인 단일 레코드 조회
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', route.params.id)
.single();
if (error) {
alert('불러오는 중 오류가 발생했습니다.');
console.error(error);
return;
}
// route.params.id에 해당하는 게시글 객체를 post.value에 할당
post.value = data;
});
</script>

상세 화면 내에 게시물 삭제 기능을 추가해보겠습니다.
// pages/post-[id]/index.vue
<template>
<h1>{{ post.title }}</h1>
<p class="cont">{{ post.content }}</p>
<button @click="deletePost(post.id)">삭제</button>
<NuxtLink to="/" class="btn">목록으로</NuxtLink>
</template>
<script setup>
const supabase = useSupabase();
const post = ref(null);
const router = useRouter();
onMounted(async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', route.params.id)
.single();
if (error) {
alert('불러오는 중 오류가 발생했습니다.');
console.error(error);
return;
}
post.value = data;
});
async function deletePost(id) {
const isConfirmed = confirm('정말로 삭제하시겠습니까?');
if (!isConfirmed) {
return;
}
// posts 테이블에서 해당 id 행 삭제
const { error } = await supabase.from('posts').delete().eq('id', id);
if (error) {
alert('삭제 중 오류가 발생했습니다.');
console.error(error);
return;
}
// 삭제 성공 후 목록 페이지로 이동
router.push('/');
}
</script>
삭제버튼을 누르면 confirm창을 띄우고, 확인버튼 클릭 시 게시물 삭제 후 메인으로 이동하도록 설정하였습니다.
(일단 디자인은 생략,,,,,,)

그런데 No. 컬럼의 값을 id로 받았더니, 중간에 삭제한 id가 그대로 출력되어 뭔가 어색합니당,,,
전체 post의 length에서 post의 index를 빼는 방식으로 내림차순이 되게 정렬하겠습니다.

// pages/index.vue
<tbody>
<tr v-for="(post, idx) in posts" :key="post.id">
<td>{{ posts.length - idx }}</td> <!-- no. 컬럼을 내림차순으로 정렬 -->
<td>{{ post.title }}</td>
<td>{{ post.created_at }}</td>
</tr>
</tbody>
마음이 편안해집니다...

수정화면 역시 dynamic routes를 사용해서 이동합니다.
create.vue에서는 게시글을 작성하면 리스트(index.vue)로 이동했지만, edit.vue에서는 수정 완료 시 해당 상세페이지로 라우팅 해주었습니다.
// pages/post-[id]/edit.vue
<template>
<h1>게시글 수정</h1>
<form @submit.prevent="update">
<label for="title">제목</label>
<input id="title" v-model="title" required />
<label for="content">내용</label>
<textarea id="content" v-model="content" required></textarea>
<button type="submit">수정</button>
</form>
</template>
<script setup>
const supabase = useSupabase();
const route = useRoute();
const router = useRouter();
const post = ref(null);
const title = ref('');
const content = ref('');
onMounted(async () => {
// posts 테이블에서 id가 route.params.id인 단일 레코드 조회
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', route.params.id)
.single();
if (error) {
alert('불러오는 중 오류가 발생했습니다.');
console.error(error);
return;
}
// 조회된 데이터를 post, title, content에 각각 할당
post.value = data;
title.value = data.title;
content.value = data.content;
});
async function update() {
// posts 테이블에서 id가 post.value.id인 레코드 업데이트
const { error } = await supabase
.from('posts')
.update({ title: title.value, content: content.value })
.eq('id', post.value.id);
if (error) {
alert('수정 중 오류가 발생했습니다.');
console.error(error);
return;
}
// 업데이트 성공 시 해당 id의 상세 페이지로 라우팅
router.push(`/post-${post.value.id}`);
}
</script>
<template>
<div>
<h1>새 글 작성</h1>
<form @submit.prevent="submit">
<div>
<label for="title">제목</label>
<input id="title" v-model="title" required />
</div>
<div>
<label for="content">내용</label>
<textarea id="content" v-model="content" required></textarea>
</div>
<button type="submit">저장</button>
</form>
</div>
</template>
<script setup>
const supabase = useSupabase()
const router = useRouter()
const title = ref('')
const content = ref('')
async function submit() {
const { error } = await supabase
.from('posts')
.insert([
{
title: title.value,
content: content.value,
created_at: new Date().toISOString().split('T')[0],
},
])
if (error) {
alert('저장 중 오류가 발생했습니다.')
console.error(error)
return
}
router.push('/')
}
</script>
<template>
<div>
<h1>게시글 목록</h1>
<table>
<thead>
<tr>
<th>No.</th>
<th>제목</th>
<th>작성일</th>
</tr>
</thead>
<tbody>
<tr
v-for="post in posts"
:key="post.id"
@click="linkToPost(post.id)"
style="cursor: pointer"
>
<td>{{ posts.length - idx }}</td>
<td>{{ post.title }}</td>
<td>{{ post.created_at }}</td>
</tr>
</tbody>
</table>
<NuxtLink to="/create">글쓰기</NuxtLink>
</div>
</template>
<script setup>
const supabase = useSupabase()
const router = useRouter()
const { data: posts } = await useAsyncData('posts', async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('id', { ascending: false })
if (error) {
console.error('Supabase fetch error:', error)
return []
}
return data
})
function linkToPost(id) {
router.push(`/post-${id}`)
}
</script>
<template>
<div>
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<NuxtLink :to="`/post-${post.id}/edit`">수정</NuxtLink>
<button @click="deletePost(post.id)">삭제</button>
<NuxtLink to="/">목록으로</NuxtLink>
</div>
</template>
<script setup>
const supabase = useSupabase()
const route = useRoute()
const router = useRouter()
const post = ref(null)
onMounted(async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', route.params.id)
.single()
if (error) {
alert('불러오는 중 오류가 발생했습니다.')
console.error(error)
return
}
post.value = data
})
async function deletePost(id) {
const confirmed = confirm('정말로 삭제하시겠습니까?')
if (!confirmed) return
const { error } = await supabase.from('posts').delete().eq('id', id)
if (error) {
alert('삭제 중 오류가 발생했습니다.')
console.error(error)
return
}
router.push('/')
}
</script>
<template>
<div>
<h1>글 수정</h1>
<form @submit.prevent="update">
<div>
<label for="title">제목</label>
<input id="title" v-model="title" required />
</div>
<div>
<label for="content">내용</label>
<textarea id="content" v-model="content" required></textarea>
</div>
<button type="submit">수정</button>
</form>
</div>
</template>
<script setup>
const supabase = useSupabase()
const route = useRoute()
const router = useRouter()
const title = ref('')
const content = ref('')
const post = ref(null)
onMounted(async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', route.params.id)
.single()
if (error) {
alert('불러오는 중 오류가 발생했습니다.')
console.error(error)
return
}
post.value = data
title.value = data.title
content.value = data.content
})
async function update() {
const { error } = await supabase
.from('posts')
.update({ title: title.value, content: content.value })
.eq('id', post.value.id)
if (error) {
alert('수정 중 오류가 발생했습니다.')
console.error(error)
return
}
router.push(`/post-${post.value.id}`)
}
</script>