しかし今のままでは、この2つはまだ「別々に存在している」だけです。
ログインしているかどうかと、記事データは何も紐づいていません。
今回は、この2つを繋げます。
具体的には次のような、実際のWebアプリでほぼ必ず必要になる仕様を実装します。
- 記事に「投稿者(作成者)」を記録する
- 自分が書いた記事だけ、編集・削除ボタンが表示される
- 他人の記事を編集・削除しようとしたら、サーバー側で拒否する
- 下書き(
published: false)の記事は、投稿者本人にしか見えない
この記事のゴール
Postモデルに投稿者(authorId)を追加する- 記事一覧・詳細ページで、投稿者本人にだけ編集/削除ボタンを表示する
- Server Actionsで「本人確認」を行い、他人のデータを操作できないようにする
なぜこれが重要なのか
認証(ログインできる)とDB(データを保存できる)は、それぞれ単体で動いていても、実は片方だけでは大きな意味を持ちません。
- 認証だけがあっても、ログインした後に「そのユーザーだけの世界」がなければ、単に名前が表示されるだけの機能になってしまいます
- DBだけがあっても、「誰のデータか」が分からなければ、全員が全員のデータを編集できる状態になってしまいます
この2つを繋げることで初めて、「マイページ」「自分の投稿一覧」「他人には見せない下書き」といった、実際のアプリらしい機能が成立します。
Postモデルに投稿者を追加する
prisma/schema.prismaを編集し、PostとUserを関連付けます。
// prisma/schema.prisma
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
posts Post[]
}User側にposts Post[]を追加し、双方向のリレーションにしている点に注目してください(#10で作ったUserモデルに、この1行を追記する形になります)。
Prismaの仕様上必須
Prismaでは1対多のリレーションを定義する際、Post側のauthorフィールドだけでなく、対になるUser側のフィールドも書かないとスキーマの検証エラーになります。
両方揃って初めて、「あるユーザーが書いた記事一覧」をPrisma側から取得できるようになります。
これにより、「あるユーザーが書いた記事一覧」をPrisma側から簡単に取得できるようになります。
編集できたら、マイグレーションを実行します。
ただし、既存のレコードがある場合
具体的には、#9からここまでの間にすでに記事を投稿している場合、エラーで止まることがあります。
これは、authorIdを**必須(NOT NULL)**カラムとして追加しようとしているのに、すでに存在する行にはauthorIdの値が1つもない、という矛盾が起きているためです。
学習用のデータであれば、DBをリセットするのが簡単です。
npx prisma migrate resetこのコマンドはDB全体(User / Session / Accountを含む)をリセットするため、#10でログインした情報も消えます。
もう一度Googleでログインし直す必要がありますが、学習用途であれば特に問題はありません。
実行後、改めてマイグレーションします。
npx prisma migrate dev --name add_post_author補足:
ログイン情報(Userなど)は残したまま、Postテーブルの中身だけを消したい場合は、npx prisma studioを開いてPostテーブルの行を手動で削除する方法もあります。
ただし、学習用プロジェクトであればprisma migrate resetの方が手軽で、結果的につまずきにくいです。
記事作成時に投稿者を記録する
#10で実装したauth.api.getSession()を使い、ログイン中のユーザーIDをauthorIdとして保存します。
// app/posts/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
throw new Error("ログインが必要です");
}
const title = formData.get("title") as string;
const content = formData.get("content") as string;
//追加
if (!title || !content) {
throw new Error("タイトルと本文は必須です");
}
await prisma.post.create({
data: {
title,
content,
//追加
authorId: session.user.id,
},
});
revalidatePath("/posts");
}💡 補足:
Auth.js(NextAuth)では、セッションにidを含めるためにcallbacks.sessionで明示的な設定が必要でした。
今回使用しているBetter Authはデフォルトでsession.user.idにユーザーIDの文字列が入っています。追加の設定は不要です。
自分の記事一覧を取得する
「マイページ」的なページとして、自分が書いた記事だけを一覧表示するページを作ります。
// app/mypage/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function MyPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/");
}
const myPosts = await prisma.post.findMany({
where: { authorId: session.user.id },
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-4">
<h1 className="text-xl font-bold">投稿した記事</h1>
<ul className="space-y-2">
{myPosts.map((post) => (
<li key={post.id} className="flex items-center justify-between">
<span>{post.title}</span>
<span className="text-xs text-muted-foreground">
{post.published ? "公開中" : "下書き"}
</span>
</li>
))}
</ul>
</div>
);
}where: { authorId: session.user.id }という条件を加えるだけで、「自分のデータだけ」というフィルタリングが完成します。
つまずきやすいポイント:
where: { authorId: session.user.id }の部分で
「オブジェクト リテラルは既知のプロパティのみ指定できます。’authorId’ は型 ‘PostWhereInput’ に存在しません。」
というTypeScriptエラーが出ることがあります。
これはschema.prismaにauthorIdを追加した後、Prisma Clientの型がまだ更新されていないのが原因です。
npx prisma generateを実行し、それでも直らない場合はエディタのTypeScriptサーバーを再起動してください(VSCodeなら、コマンドパレットから「TypeScript: Restart TS Server」)。
#9の「つまずきやすいポイント」で触れたパターンと根っこは同じで、スキーマを変更するたびに起こりうるので覚えておくと安心です。
💡 補足:
Auth.jsには/api/auth/signinという、ライブラリ側が自動生成するログイン画面がありましたが、Better Authにはそれに相当するページがありません。
未ログイン時はトップページ(/)にリダイレクトし、#10・#12でHeaderに配置した「Googleでログイン」ボタンからログインしてもらう、という構成にしています。
編集・削除ボタンを本人にだけ表示する
記事一覧・詳細ページ側でも、投稿者本人かどうかを判定し、UIを出し分けます。
// app/posts/[id]/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import PostActions from "./post-actions";
export default async function PostDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true },
});
if (!post) notFound();
// 下書きは投稿者本人以外には見せない
if (!post.published && post.authorId !== session?.user?.id) {
notFound();
}
const isOwner = post.authorId === session?.user?.id;
return (
<article className="space-y-4">
<h1 className="text-2xl font-bold">{post.title}</h1>
<p className="text-sm text-muted-foreground">
投稿者:{post.author.name}
</p>
<p>{post.content}</p>
{isOwner && (
<PostActions
postId={post.id}
initialTitle={post.title}
initialContent={post.content}
/>
)}
</article>
);
}PostActionsはまだ作っていないので、これから作ります。
編集ボタンを押すとインライン編集フォームに切り替わり、送信すると#13後半で実装するupdatePost(Server Action)を呼び出す、という構成です。
// app/posts/[id]/post-actions.tsx
"use client";
import { useState } from "react";
import { updatePost, deletePost } from "../actions";
export default function PostActions({
postId,
initialTitle,
initialContent,
}: {
postId: number;
initialTitle: string;
initialContent: string;
}) {
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<form
action={async (formData) => {
await updatePost(postId, formData);
setIsEditing(false);
}}
className="space-y-2"
>
<input
type="text"
name="title"
defaultValue={initialTitle}
className="w-full rounded-md border p-2"
required
/>
<textarea
name="content"
defaultValue={initialContent}
rows={6}
className="w-full rounded-md border p-2"
required
/>
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-black px-3 py-1 text-sm text-white">
保存
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
className="rounded-md border px-3 py-1 text-sm"
>
キャンセル
</button>
</div>
</form>
);
}
return (
<div className="flex gap-2">
<button
onClick={() => setIsEditing(true)}
className="rounded-md border px-3 py-1 text-sm"
>
編集
</button>
<form action={() => deletePost(postId)}>
<button type="submit" className="rounded-md border border-red-500 px-3 py-1 text-sm text-red-600">
削除
</button>
</form>
</div>
);
}
updatePost/deletePostは、この直後の「Server Actions側で本人確認を行う」で実装します。ここではまだ存在しないので、次のセクションまで進めてから動作確認してください。
isEditingがtrueのときはインライン編集フォーム、
falseのときは「編集」「削除」ボタンを表示する、
というシンプルな切り替えです。
専用の編集ページ(/posts/[id]/edit)を別途作らずに、詳細ページの中で完結させています。
isOwnerの判定は「見た目を出し分けるため」のものであり、これ単体ではセキュリティ対策になりません。
あくまで補助的なものであり、実際のガードは次に説明するServer Actions側で行います。
「新しい記事を書く」ボタンの表示も制御する
#12でHeaderに配置した「新しい記事を書く」ボタンは、実はまだ未ログインの人にも表示されたままでした。
ここまででsessionを扱う準備が整ったので、同じ考え方でこのボタンにも出し分けを適用します。
// components/header.tsx
"use client";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import NewPostDialog from "@/app/posts/_components/new-post-dialog";
export default function Header({
authSlot,
isLoggedIn,
}: {
authSlot: React.ReactNode;
isLoggedIn: boolean;
}) {
const open = usePostDialogStore((state) => state.open);
return (
<header className="flex items-center justify-between p-4">
<h1 className="text-lg font-bold">ミニブログ</h1>
<div className="flex items-center gap-3">
{isLoggedIn && (
<button
onClick={open}
className="rounded-md bg-black px-4 py-2 text-sm text-white"
>
新しい記事を書く
</button>
)}
{authSlot}
</div>
<NewPostDialog />
</header>
);
}呼び出し元のapp/layout.tsxから、セッションの有無をisLoggedInとして渡します。
// app/layout.tsx
import Header from "@/components/header";
import AuthButtons from "@/components/auth-buttons";
import { Toaster } from "@/components/ui/sonner";
import { Providers } from "./providers";
//追加
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
//追加 asyncにする
export default async function RootLayout({ children }: { children: React.ReactNode }) {
//追加
const session = await auth.api.getSession({ headers: await headers() });
return (
<html lang="ja">
<body className="min-h-full flex flex-col">
<Providers>
{/* 追加 */}
<Header authSlot={<AuthButtons />} isLoggedIn={!!session?.user} />
<main className="flex-1">{children}</main>
<Toaster />
</Providers>
</body>
</html>
);
}これもisOwnerと同じく、あくまでUI上の配慮です。
ボタンを隠しても、それだけで「未ログインでも投稿できてしまう」問題が解決するわけではありません。
実際にログインなしでの投稿を防いでいるのは、#12から実装しているcreatePost内のauth.api.getSession()によるチェックです。
UIでの出し分けとServer Actionsでの実チェックは、必ずセットで実装します。
Server Actions側で本人確認を行う(本当の防衛ライン)
UIでボタンを隠していても、Server Actionsのエンドポイントを直接呼び出されてしまえば、他人の記事を編集・削除できてしまいます。必ずサーバー側でも所有者チェックを行います。
// app/posts/actions.ts
import { redirect } from "next/navigation";//追加
//・・・・createPostは省略
//idからposeIdに変更
export async function updatePost(postId: number, formData: FormData) {
//追記 ↓
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
throw new Error("ログインが必要です");
}
const post = await prisma.post.findUnique({ where: { id: postId } });
if (!post) {
throw new Error("記事が見つかりません");
}
if (post.authorId !== session.user.id) {
throw new Error("この記事を編集する権限がありません");
}
//追記 ↑
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await prisma.post.update({
where: { id: postId }, //変更
data: { title, content },
});
revalidatePath(`/posts/${postId}`);
}
//idからposeIdに変更
export async function deletePost(postId: number) {
//追記 ↓
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
throw new Error("ログインが必要です");
}
const post = await prisma.post.findUnique({ where: { id: postId } });
if (!post || post.authorId !== session.user.id) {
throw new Error("この記事を削除する権限がありません");
}
//追記 ↑
await prisma.post.delete({ where: { id: postId } }); //変更
revalidatePath("/posts");
redirect("/posts");//追加
}⚠️ メソッドの引数をidからposeIdに変更しています。(コードを読みやすくするため)
updateやdeleteを呼ぶ前に、必ず
- ログインしているか
- 対象のデータが存在するか
- そのデータの所有者が自分かどうか
の3点をチェックする、というパターンを徹底します。
この3ステップは、認証×DBを扱うアプリであればほぼ必ず登場するので、型として覚えてしまうとよいでしょう。
Prismaのwhere条件でまとめて絞り込む方法
上記は分かりやすさのためにfindUnique→チェック→updateという3段階で書きましたが、where条件にauthorIdを含めることで1回のクエリにまとめることもできます。
export async function deletePostShort(postId: number) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) throw new Error("ログインが必要です");
const result = await prisma.post.deleteMany({
where: {
id: postId,
authorId: session.user.id, // 所有者でなければ0件がヒットし、削除されない
},
});
if (result.count === 0) {
throw new Error("この記事を削除する権限がありません、または存在しません");
}
revalidatePath("/posts");
}deleteMany / updateManyは「条件に一致した件数」を返すため、count === 0であれば「該当データがない」または「所有者が違う」と判断できます。
クエリ回数を1回に減らせるという利点があります。
動作確認
- 2つの異なるGoogleアカウントでそれぞれログインし、それぞれ記事を投稿する
- Aのアカウントでログインした状態で、Aが投稿した記事にだけ編集・削除ボタンが表示されることを確認
- Bの記事の詳細ページで、編集・削除ボタンが表示されないことを確認
- ブラウザの開発者ツールなどからBの記事IDを指定して
deletePostを直接呼び出そうとしても、「権限がありません」エラーになることを確認
まとめ
この記事では、以下を実装しました。
PostモデルにauthorIdを追加し、Userとのリレーションを構築- 記事作成時に、ログイン中のユーザーIDを投稿者として記録
- UIでは投稿者本人にだけ編集/削除ボタン、ログインユーザーにだけ「新しい記事を書く」ボタンを表示
- Server Actionsでは「ログイン確認→データ存在確認→所有者確認」の3段階チェックを徹底
UIでの出し分けとサーバー側での権限チェックは、必ずセットで実装するということを覚えておいてください。片方だけでは、見た目は正しくても実際にはセキュリティホールになってしまいます。
次回の#14 記事コンテンツを強化する — Markdownレンダリングと画像アップロードでは、記事本文をMarkdownで書けるようにし、アイキャッチ画像のアップロード機能を追加します。


























