9.4 שליפת נתונים וסרבר אקשנס הרצאה
שליפת נתונים וסרבר אקשנס - Data Fetching and Server Actions¶
בשיעור זה נלמד איך לשלוף נתונים ב-Next.js ואיך להשתמש ב-Server Actions כדי לבצע פעולות כתיבה מהקליינט לשרת, בלי ליצור API routes.
שליפת נתונים ב-Server Components¶
ב-App Router, אפשר לשלוף נתונים ישירות בקומפוננטה באמצעות async/await:
// app/posts/page.tsx
interface Post {
id: number;
title: string;
body: string;
}
export default async function PostsPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts: Post[] = await res.json();
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">פוסטים</h1>
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="border p-4 rounded">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600 mt-2">{post.body}</p>
</li>
))}
</ul>
</div>
);
}
- אין צורך ב-useEffect או useState
- הנתונים נשלפים בשרת לפני שה-HTML נשלח לדפדפן
- מנועי חיפוש מקבלים HTML מוכן עם כל הנתונים
גישה ישירה לבסיס נתונים¶
אפשר גם לגשת ישירות לבסיס נתונים:
// app/users/page.tsx
import { prisma } from "@/lib/prisma";
export default async function UsersPage() {
const users = await prisma.user.findMany({
include: { posts: { take: 3 } },
});
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">משתמשים</h1>
{users.map((user) => (
<div key={user.id} className="border p-4 rounded mb-4">
<h2 className="text-xl font-semibold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
<p className="mt-2">{user.posts.length} פוסטים אחרונים</p>
</div>
))}
</div>
);
}
שליטה בקאשינג - Caching with fetch¶
נקסט מרחיב את ה-fetch API עם אפשרויות קאשינג:
ללא קאש - נתונים דינמיים¶
- הנתונים נשלפים מחדש בכל בקשה
- מתאים לנתונים שמשתנים תדיר (מחירי מניות, התראות)
קאש סטטי - ברירת מחדל¶
- הנתונים נשמרים ומוגשים מהקאש
- מתאים לנתונים שלא משתנים (תוכן סטטי)
קאש עם עדכון מחזורי - Revalidation¶
// עדכון לפי זמן (ISR)
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 60 }, // עדכון כל 60 שניות
});
// עדכון לפי תגית
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
revalidateמגדיר כל כמה שניות הנתונים מתעדכניםtagsמאפשר עדכון ידני על ידי תגית
סרבר אקשנס - Server Actions¶
Server Actions הם פונקציות שרצות בשרת אבל אפשר לקרוא להן מהקליינט:
// app/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await prisma.post.create({
data: { title, content },
});
revalidatePath("/posts");
}
- ההנחיה
"use server"בתחילת הקובץ מגדירה שכל הפונקציות בקובץ הן Server Actions - הפונקציות רצות בשרת גם כשנקראות מהקליינט
revalidatePathמרענן את הקאש של נתיב ספציפי
שימוש ב-Server Actions עם טפסים¶
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPostPage() {
return (
<div className="max-w-lg mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">פוסט חדש</h1>
<form action={createPost} className="space-y-4">
<div>
<label className="block mb-1 font-semibold">כותרת</label>
<input
name="title"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block mb-1 font-semibold">תוכן</label>
<textarea
name="content"
required
rows={5}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
פרסם
</button>
</form>
</div>
);
}
- מעבירים את ה-Server Action ל-
actionשל הטופס - כשהטופס נשלח, הפונקציה רצה בשרת
- הדפדפן שולח את הנתונים כ-FormData
- זה עובד גם בלי JavaScript מופעל בדפדפן (Progressive Enhancement)
שימוש ב-Server Actions מ-Client Components¶
// app/posts/LikeButton.tsx
"use client";
import { useState } from "react";
import { likePost } from "@/app/actions";
export default function LikeButton({ postId, initialLikes }: {
postId: number;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async () => {
setIsLoading(true);
try {
const newLikes = await likePost(postId);
setLikes(newLikes);
} finally {
setIsLoading(false);
}
};
return (
<button
onClick={handleLike}
disabled={isLoading}
className="flex items-center gap-2 px-3 py-1 border rounded hover:bg-gray-50"
>
{isLoading ? "..." : `לייק (${likes})`}
</button>
);
}
// app/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function likePost(postId: number): Promise<number> {
const post = await prisma.post.update({
where: { id: postId },
data: { likes: { increment: 1 } },
});
revalidatePath("/posts");
return post.likes;
}
עדכון קאש - Revalidation¶
עדכון לפי נתיב - revalidatePath¶
"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(id: number, data: { title: string }) {
await prisma.post.update({ where: { id }, data });
// מרענן את הקאש של הנתיב הספציפי
revalidatePath("/posts");
// אפשר גם לרענן נתיב דינמי ספציפי
revalidatePath(`/posts/${id}`);
// או לרענן את כל הלייאאוט
revalidatePath("/posts", "layout");
}
עדכון לפי תגית - revalidateTag¶
// שליפה עם תגית
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
// עדכון לפי תגית
"use server";
import { revalidateTag } from "next/cache";
export async function refreshPosts() {
revalidateTag("posts");
}
ממשק טעינה וסטרימינג - Loading UI and Streaming¶
קובץ loading.tsx¶
// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse border p-4 rounded">
<div className="h-6 bg-gray-200 rounded w-2/3 mb-2" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-4/5 mt-1" />
</div>
))}
</div>
);
}
סטרימינג עם Suspense¶
אפשר לסטרים חלקים שונים של הדף בנפרד:
// app/dashboard/page.tsx
import { Suspense } from "react";
import RevenueChart from "./RevenueChart";
import LatestOrders from "./LatestOrders";
import Stats from "./Stats";
export default function DashboardPage() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">דשבורד</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<div className="grid grid-cols-2 gap-6 mt-6">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<LatestOrders />
</Suspense>
</div>
</div>
);
}
function StatsSkeleton() {
return <div className="h-24 bg-gray-100 rounded animate-pulse" />;
}
function ChartSkeleton() {
return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}
function OrdersSkeleton() {
return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}
// app/dashboard/Stats.tsx
import { prisma } from "@/lib/prisma";
export default async function Stats() {
const [users, orders, revenue] = await Promise.all([
prisma.user.count(),
prisma.order.count(),
prisma.order.aggregate({ _sum: { total: true } }),
]);
return (
<div className="grid grid-cols-3 gap-4">
<div className="bg-blue-50 p-4 rounded">
<div className="text-3xl font-bold">{users}</div>
<div>משתמשים</div>
</div>
<div className="bg-green-50 p-4 rounded">
<div className="text-3xl font-bold">{orders}</div>
<div>הזמנות</div>
</div>
<div className="bg-yellow-50 p-4 rounded">
<div className="text-3xl font-bold">{revenue._sum.total} ש"ח</div>
<div>הכנסות</div>
</div>
</div>
);
}
- כל קומפוננטה בתוך
Suspenseנטענת בנפרד - ה-fallback מוצג בזמן שהקומפוננטה נטענת
- המשתמש רואה חלקים של הדף ברגע שהם מוכנים
דוגמה מלאה - בלוג עם CRUD¶
// app/actions/posts.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
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 },
});
revalidatePath("/posts");
redirect("/posts");
}
export async function updatePost(id: number, formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await prisma.post.update({
where: { id },
data: { title, content },
});
revalidatePath("/posts");
revalidatePath(`/posts/${id}`);
redirect(`/posts/${id}`);
}
export async function deletePost(id: number) {
await prisma.post.delete({
where: { id },
});
revalidatePath("/posts");
redirect("/posts");
}
// app/posts/page.tsx
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function PostsPage() {
const posts = await prisma.post.findMany({
orderBy: { createdAt: "desc" },
});
return (
<div className="max-w-3xl mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">פוסטים</h1>
<Link
href="/posts/new"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
פוסט חדש
</Link>
</div>
{posts.length === 0 ? (
<p className="text-gray-500">אין פוסטים עדיין</p>
) : (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="border p-4 rounded hover:shadow">
<Link href={`/posts/${post.id}`}>
<h2 className="text-xl font-semibold hover:text-blue-600">
{post.title}
</h2>
<p className="text-gray-600 mt-1 line-clamp-2">
{post.content}
</p>
<time className="text-sm text-gray-400 mt-2 block">
{new Date(post.createdAt).toLocaleDateString("he-IL")}
</time>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";
export default function NewPostPage() {
return (
<div className="max-w-lg mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">פוסט חדש</h1>
<form action={createPost} className="space-y-4">
<div>
<label className="block mb-1 font-semibold">כותרת</label>
<input
name="title"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block mb-1 font-semibold">תוכן</label>
<textarea
name="content"
required
rows={8}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
פרסם
</button>
</form>
</div>
);
}
טיפול בשגיאות ב-Server Actions¶
"use server";
interface ActionResult {
success: boolean;
error?: string;
}
export async function createPost(formData: FormData): Promise<ActionResult> {
try {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || title.length < 3) {
return { success: false, error: "הכותרת חייבת להכיל לפחות 3 תווים" };
}
if (!content || content.length < 10) {
return { success: false, error: "התוכן חייב להכיל לפחות 10 תווים" };
}
await prisma.post.create({ data: { title, content } });
revalidatePath("/posts");
return { success: true };
} catch (error) {
return { success: false, error: "שגיאה ביצירת הפוסט" };
}
}
// שימוש בקליינט עם טיפול בשגיאות
"use client";
import { useState } from "react";
import { createPost } from "@/app/actions/posts";
export default function PostForm() {
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (formData: FormData) => {
const result = await createPost(formData);
if (!result.success) {
setError(result.error || "שגיאה לא ידועה");
}
};
return (
<form action={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded">{error}</div>
)}
<input name="title" required className="w-full border rounded px-3 py-2" />
<textarea name="content" required className="w-full border rounded px-3 py-2" />
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
פרסם
</button>
</form>
);
}
סיכום¶
- ב-Server Components שולפים נתונים עם async/await ישירות בקומפוננטה
- fetch של נקסט תומך בקאשינג:
cache,revalidate,tags - Server Actions הם פונקציות שרת שנקראות מהקליינט עם
"use server" - אפשר להשתמש ב-Server Actions עם form action או מ-Client Components
revalidatePathו-revalidateTagמרעננים את הקאש- Suspense מאפשר סטרימינג של חלקים שונים בדף בנפרד
- Server Actions תומכים ב-Progressive Enhancement (עובדים גם בלי JS)