はじめに
今回は、Next.jsとNest.jsとPrismaを使用して簡単なブログを作成してみます!
以下のような完成形を目指します!
前提条件
・Next.jsとNest.jsとPrismaの環境構築を完了していること
まだの方は以下の記事で解説してるので、こちらで構築を済ませてから実装してみてください。
目次
- フロント側の実装
- バックエンド側の実装
フロント側
まずはpage.tsxのファイル内を、以下のようにコードを記述してください。
"use client";
import styles from "@/styles/home.module.css";
import { getAllPosts, PostBlogData } from "@/utils/api";
import { PostType } from "@/utils/Types";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Home() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [author, setAuthor] = useState("");
const [posts, setPosts] = useState<PostType[]>([]);
useEffect(() => {
const fetchData = async () => {
const allPosts: PostType[] = await getAllPosts();
setPosts(allPosts);
};
fetchData();
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const newPost = {
title,
content,
author,
createdAt: new Date().toISOString(),
};
try {
await PostBlogData(newPost);
window.location.reload();
setTitle("");
setContent("");
setAuthor("");
} catch (error) {
}
};
return (
<>
<p className="text-center text-4xl mt-5 font-bold">個人Blog</p>
<div className={styles.container}>
<div>
<form onSubmit={handleSubmit} className="max-w-xl mx-auto p-4 bg-white shadow-md rounded-lg mb-5">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="title">
Title:
</label>
<input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="content">
Content:
</label>
<textarea id="content" value={content} onChange={(e) => setContent(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline h-32"></textarea>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="author">
Author:
</label>
<input id="author" type="text" value={author} onChange={(e) => setAuthor(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" />
</div>
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
登録する
</button>
</form>
</div>
<ul className={styles.postList}>
{posts.map((post: PostType) => {
return (
<Link href={`/posts/${post.id}`}>
<li className={styles.post} key={post.id}>
<h2 className={styles.title}>{post.title}</h2>
<p className={styles.author}>By {post.author}</p>
</li>
</Link>
);
})}
</ul>
</div>
</>
);
}
まだ上記のコードだけだと、「PostTypeが見つかりません」と、「stylesが見つかりません」エラーが出ると思うので以下の二つのディレクトリを作成し、コードを記述してください。
// サーバーに送信する型 (idを含まない)
export type PostCreateInput = {
title: string;
content: string;
author: string;
createdAt: string;
};
// サーバーから受信する型 (idを含む)
export interface PostType extends PostCreateInput {
id: number;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.author {
font-size: 1rem;
color: #777;
margin-bottom: 1rem;
}
.postList {
list-style: none;
padding: 0;
margin: 0;
}
.post {
margin-bottom: 2rem;
border-bottom: 1px solid #5189ba;
padding-bottom: 1rem;
}
それでは上記のpage.tsxファイルのコードの解説をしていきます。まず、useState関数を使って、保存するカラム(title、content、author、createdAt)を用意します。
登録ボタンを押すと、formタグに指定されているhandleSubmit関数が呼ばれます。この関数では、まず入力された情報(title、content、authorなど)をnewPostオブジェクトに格納し、そのデータをPostBlogData関数に送信します(この関数の中身については後で説明します)。async/awaitが使われているので、PostBlogDataの返り値が返ってくるまで次の処理に進みません。これにより、データが正しく送信されたときにのみページがリロードされるようになります。送信が成功した場合、入力フォームに残っているデータを空にします。
ここまでで、入力された情報をバックエンドに送信し、保存し、ページをリロードする部分が実装できました。しかし、まだ登録したデータを表示する部分が実装されていません。それを実現するのが、上にあるuseEffect関数です。この関数によって、getAllPosts関数(後で中身を説明します)を使ってデータベースに保存されている全てのデータを取得し、setPosts関数を呼び出してposts変数の中身を更新します。これにより、ulタグ内でposts変数のデータがmap関数によって一つずつ表示されます。
これで、データの登録、保存、そしてサイト上での表示ができるようになりました。
ここからは、先ほど登場した、PostBlogData関数であったり、getAllPosts関数について解説していきます。
まず、utilsディレクトリ内にapi.tsファイルを作成します。以下のコードをコピペしてください。
import { PostCreateInput } from "./Types";
export async function getAllPosts() {
try {
const response = await fetch("http://localhost:5050/posts", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
cache: "no-store",
});
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch posts:", error);
return [];
}
}
export async function PostBlogData(postData: PostCreateInput) {
try {
const response = await fetch("http://localhost:5050/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(postData),
cache: "no-store",
});
const data = await response.json();
console.log("data" + data);
return data;
} catch (error) {
console.error("Failed to fetch posts:", error);
return [];
}
}
先に登場したPostBlogData関数について解説します。
まず、try{}catch{}構文について説明します。この構文は、tryブロック内のコードが実行され、その中でエラーが発生した場合にcatchブロック内のコードが実行される仕組みです。
次に、fetchメソッドについて説明します。このメソッドは、ネットワークリクエストを行うために使用されます。第一引数にはアクセスするエンドポイント(URL)を指定し、第二引数にはリクエストのオプションを指定します。例えば、今回はデータを送信するため、methodはPOSTに設定されています。
通常、バックエンドにデータを送信する際には、データをJSON形式に変換してから送信します。JSON.stringify(postData)の部分でpostDataをJSON形式に変換しています。また、fetch関数にはawaitが使われているため、バックエンドからのレスポンスを待ってから次の処理に進みます。レスポンスが返ってきたら、それをJSON形式に変換し、data変数に格納します。
getAllPosts関数もほとんど同じですが、今回はデータベースに保存されている全てのデータを取得するため、GETメソッドが使われています。
これでフロント側の実装は以上になります!次は、バックエンド側に移って、データを送信されたときの処理などを記述していきます!
バックエンド側
まずは、ターミナルで以下の3つのコマンドを順番に実行してください。
これにより、posts関連のコントローラー、メソッドの処理を記述するserviceなどが作成されます。
拡張子でspec.tsなどのファイルはテストコードを記述するファイルになるので、今回はいじりません。
nest generate module posts
nest generate controller posts
nest generate service posts
次に、postsのデータの型をあらかじめ定義しておきます。
export interface PostType{
id: number;
title: string;
content: string;
author: string;
createdAt: string;
}
次に、posts.controller.tsを記述していきます。
コントローラーは、どのメソッドを呼び出すかを指示する役割を持っています。ゲームのコントローラーと同じように、ボタンを押すことで特定の指示を飛ばすのと同じ意味合いであると考えたら理解しやすいかもです。
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostType } from './post.interface';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
findAll() {
return this.postsService.findAll();
}
@Post()
async create(@Body() post: PostType): Promise<void> {
await this.postsService.create(post);
}
上記のコードについて解説していきます。
まずcontroller(posts)のposts部分は、フロントエンド側でエンドポイントとして送ったhttp://localhost:3000/posts
のpostsに対応しています。これにより、フロントエンドで作成したエンドポイント(URL)がバックエンド側に送信された際、対応する処理を実行してくれます。
次に、@Getと@Postの部分です。これは、上記のurlによってどのコントローラーを実行するかはわかりましたが、どのメソッドを実行するかはurlでは判別つきません。ここで出てくるのがフロント側でfetch関数の第二引数で送信したリクエストのオプションが役に立ちます。getAllPost関数ではGETメソッドとして送信しているので、このurlが送信された際には、@Get()が実行されます。同様にPostメソッドの場合は、@Post()が実行されます。
ここからは、その具体的なメソッドの中身をservice.tsに記述していきます。
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PostType } from './post.interface';
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
async findAll(): Promise<PostType[]> {
const posts = await this.prisma.posts.findMany();
return posts as PostType[];
}
async create(post: PostType): Promise<void> {
const createdPost = await this.prisma.posts.create({
data: post,
});
}
}
上記のコードの解説をしていきます。
まず、findAll()メソッドについて説明します。prisma.posts.findMany()と記述することで、Prismaのデータベースに登録されているpostsに関連するすべてのデータを取得することができます。つまり、このfindAllメソッドをコントローラーで実行することにより、データベース内のすべての投稿データを取得できるわけです。
次に、createメソッドについて説明します。このメソッドもPrismaがあらかじめ用意してくれているもので、prisma.posts.create()と記述することで、引数として渡されたデータをデータベースに登録することができます。
このように、Prismaを使用すると、SQLクエリ文を書かなくても、サーバー上でメソッドを記述するだけでデータベースとのやり取りができるため、非常に便利です。
ここまで実装すると実際にデータを投稿して表示されるとこまで実装出来ると思います!
終わりに
これでNext.jsとNest.jsと使って簡単な投稿機能の実装は以上になります!
少しでも開発の手助けとなれば幸いです。
ここまで読んで頂いた方ありがとうございました!