9.5 נתיבים דינמיים ונתיבי API פתרון
פתרון - נתיבים דינמיים ונתיבי API - Dynamic Routes and API Routes¶
פתרון תרגיל 1 - בלוג עם נתיבים דינמיים¶
// app/blog/page.tsx
import Link from "next/link";
interface Post {
id: number;
title: string;
body: string;
}
export default async function BlogPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts: Post[] = await res.json();
return (
<div className="max-w-3xl mx-auto 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 hover:shadow">
<Link href={`/blog/${post.id}`}>
<h2 className="text-xl font-semibold hover:text-blue-600">
{post.title}
</h2>
<p className="text-gray-600 mt-1">
{post.body.substring(0, 50)}...
</p>
</Link>
</li>
))}
</ul>
</div>
);
}
// app/blog/[slug]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
interface Post {
id: number;
title: string;
body: string;
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.slug}`
);
if (!res.ok) {
notFound();
}
const post: Post = await res.json();
return (
<article className="max-w-3xl mx-auto p-6">
<Link href="/blog" className="text-blue-500 hover:underline mb-4 block">
חזרה לרשימה
</Link>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-gray-500 mb-6">פוסט מספר {post.id}</p>
<div className="text-lg leading-relaxed">{post.body}</div>
</article>
);
}
// app/blog/[slug]/not-found.tsx
import Link from "next/link";
export default function PostNotFound() {
return (
<div className="text-center p-8">
<h2 className="text-3xl font-bold">הפוסט לא נמצא</h2>
<p className="mt-2 text-gray-600">הפוסט שחיפשת לא קיים</p>
<Link href="/blog" className="mt-4 inline-block text-blue-500 underline">
חזרה לבלוג
</Link>
</div>
);
}
פתרון תרגיל 2 - חנות עם נתיבים מקוננים¶
// lib/products.ts
export interface Product {
id: string;
name: string;
category: string;
price: number;
description: string;
}
export const products: Product[] = [
{ id: "1", name: "נעלי ריצה", category: "shoes", price: 350, description: "נעלי ריצה מקצועיות" },
{ id: "2", name: "נעלי הליכה", category: "shoes", price: 280, description: "נעלי הליכה נוחות" },
{ id: "3", name: "חולצת כותנה", category: "shirts", price: 120, description: "חולצה מ-100% כותנה" },
{ id: "4", name: "חולצת פולו", category: "shirts", price: 180, description: "חולצת פולו אלגנטית" },
{ id: "5", name: "מכנסי ג'ינס", category: "pants", price: 250, description: "ג'ינס קלאסי" },
{ id: "6", name: "מכנסיים קצרים", category: "pants", price: 150, description: "מכנסי ספורט קצרים" },
];
export const categories: Record<string, string> = {
shoes: "נעליים",
shirts: "חולצות",
pants: "מכנסיים",
};
// app/shop/page.tsx
import Link from "next/link";
import { products, categories } from "@/lib/products";
export default function ShopPage() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-6">החנות</h1>
<div className="flex gap-4 mb-6">
{Object.entries(categories).map(([key, name]) => (
<Link
key={key}
href={`/shop/${key}`}
className="px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
{name}
</Link>
))}
</div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<Link
key={product.id}
href={`/shop/${product.category}/${product.id}`}
className="border p-4 rounded hover:shadow"
>
<h2 className="font-semibold">{product.name}</h2>
<p className="text-gray-600">{product.price} ש"ח</p>
</Link>
))}
</div>
</div>
);
}
// app/shop/[category]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { products, categories } from "@/lib/products";
export default function CategoryPage({
params,
}: {
params: { category: string };
}) {
const categoryName = categories[params.category];
if (!categoryName) notFound();
const categoryProducts = products.filter(
(p) => p.category === params.category
);
return (
<div className="p-6">
<nav className="text-sm text-gray-500 mb-4">
<Link href="/shop" className="hover:underline">חנות</Link>
{" > "}
<span>{categoryName}</span>
</nav>
<h1 className="text-3xl font-bold mb-6">{categoryName}</h1>
<div className="grid grid-cols-3 gap-4">
{categoryProducts.map((product) => (
<Link
key={product.id}
href={`/shop/${params.category}/${product.id}`}
className="border p-4 rounded hover:shadow"
>
<h2 className="font-semibold">{product.name}</h2>
<p className="text-gray-600">{product.price} ש"ח</p>
</Link>
))}
</div>
</div>
);
}
// app/shop/[category]/[productId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { products, categories } from "@/lib/products";
export default function ProductPage({
params,
}: {
params: { category: string; productId: string };
}) {
const product = products.find((p) => p.id === params.productId);
if (!product) notFound();
const categoryName = categories[params.category];
return (
<div className="max-w-2xl mx-auto p-6">
<nav className="text-sm text-gray-500 mb-4">
<Link href="/shop" className="hover:underline">חנות</Link>
{" > "}
<Link href={`/shop/${params.category}`} className="hover:underline">
{categoryName}
</Link>
{" > "}
<span>{product.name}</span>
</nav>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-2xl text-green-600 font-bold mb-4">
{product.price} ש"ח
</p>
<p className="text-gray-700 text-lg">{product.description}</p>
<button className="mt-6 px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600">
הוסף לסל
</button>
</div>
);
}
פתרון תרגיל 3 - מערכת תיעוד עם Catch-All¶
// app/docs/[[...slug]]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
const docs: Record<string, { title: string; content: string }> = {
"getting-started": {
title: "התחלה מהירה",
content: "מדריך להתחלה מהירה עם הפרויקט. התקינו את הפרויקט והריצו את שרת הפיתוח.",
},
"api/auth": {
title: "API - אותנטיקציה",
content: "תיעוד של נקודות הקצה לאותנטיקציה: הרשמה, התחברות, ועדכון סיסמה.",
},
"api/users": {
title: "API - משתמשים",
content: "תיעוד של נקודות הקצה לניהול משתמשים: רשימה, פרטים, עדכון ומחיקה.",
},
};
const allDocs = Object.entries(docs).map(([path, doc]) => ({
path,
...doc,
}));
export default function DocsPage({
params,
}: {
params: { slug?: string[] };
}) {
const slug = params.slug;
// דף ראשי של התיעוד
if (!slug) {
return (
<div className="flex">
<Sidebar currentPath="" />
<main className="flex-1 p-6">
<h1 className="text-3xl font-bold mb-6">תיעוד</h1>
<p className="mb-4">ברוכים הבאים לתיעוד. בחרו נושא מהתפריט.</p>
<ul className="space-y-2">
{allDocs.map((doc) => (
<li key={doc.path}>
<Link
href={`/docs/${doc.path}`}
className="text-blue-500 hover:underline"
>
{doc.title}
</Link>
</li>
))}
</ul>
</main>
</div>
);
}
const path = slug.join("/");
const doc = docs[path];
if (!doc) {
notFound();
}
return (
<div className="flex">
<Sidebar currentPath={path} />
<main className="flex-1 p-6">
<h1 className="text-3xl font-bold mb-4">{doc.title}</h1>
<div className="prose">{doc.content}</div>
</main>
</div>
);
}
function Sidebar({ currentPath }: { currentPath: string }) {
return (
<aside className="w-64 bg-gray-50 p-4 min-h-screen border-l">
<h2 className="font-bold mb-4">תפריט</h2>
<nav className="space-y-1">
{allDocs.map((doc) => (
<Link
key={doc.path}
href={`/docs/${doc.path}`}
className={`block p-2 rounded ${
currentPath === doc.path
? "bg-blue-100 text-blue-700 font-bold"
: "hover:bg-gray-200"
}`}
>
{doc.title}
</Link>
))}
</nav>
</aside>
);
}
פתרון תרגיל 4 - API מלא עם Route Handlers¶
// lib/todosApi.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: string;
}
let todos: Todo[] = [
{ id: 1, title: "ללמוד Next.js", completed: false, createdAt: new Date().toISOString() },
{ id: 2, title: "לבנות API", completed: true, createdAt: new Date().toISOString() },
];
let nextId = 3;
export const todosDB = {
getAll: (status?: string) => {
if (status === "completed") return todos.filter((t) => t.completed);
if (status === "active") return todos.filter((t) => !t.completed);
return todos;
},
getById: (id: number) => todos.find((t) => t.id === id),
create: (title: string) => {
const todo: Todo = {
id: nextId++,
title,
completed: false,
createdAt: new Date().toISOString(),
};
todos.push(todo);
return todo;
},
update: (id: number, data: Partial<Todo>) => {
const index = todos.findIndex((t) => t.id === id);
if (index === -1) return null;
todos[index] = { ...todos[index], ...data };
return todos[index];
},
delete: (id: number) => {
const index = todos.findIndex((t) => t.id === id);
if (index === -1) return false;
todos.splice(index, 1);
return true;
},
};
// app/api/todos/route.ts
import { NextRequest, NextResponse } from "next/server";
import { todosDB } from "@/lib/todosApi";
export async function GET(request: NextRequest) {
const status = request.nextUrl.searchParams.get("status") || undefined;
const todos = todosDB.getAll(status);
return NextResponse.json(todos);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title } = body;
if (!title || typeof title !== "string") {
return NextResponse.json(
{ error: "שדה title הוא חובה" },
{ status: 400 }
);
}
if (title.trim().length < 2) {
return NextResponse.json(
{ error: "הכותרת חייבת להכיל לפחות 2 תווים" },
{ status: 400 }
);
}
const todo = todosDB.create(title.trim());
return NextResponse.json(todo, { status: 201 });
} catch {
return NextResponse.json({ error: "גוף הבקשה לא תקין" }, { status: 400 });
}
}
// app/api/todos/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { todosDB } from "@/lib/todosApi";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const todo = todosDB.getById(parseInt(params.id));
if (!todo) {
return NextResponse.json({ error: "משימה לא נמצאה" }, { status: 404 });
}
return NextResponse.json(todo);
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const todo = todosDB.update(parseInt(params.id), body);
if (!todo) {
return NextResponse.json({ error: "משימה לא נמצאה" }, { status: 404 });
}
return NextResponse.json(todo);
} catch {
return NextResponse.json({ error: "גוף הבקשה לא תקין" }, { status: 400 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const deleted = todosDB.delete(parseInt(params.id));
if (!deleted) {
return NextResponse.json({ error: "משימה לא נמצאה" }, { status: 404 });
}
return NextResponse.json({ message: "המשימה נמחקה" });
}
פתרון תרגיל 5 - generateStaticParams¶
// app/blog/[slug]/page.tsx (מעודכן)
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface Post {
id: number;
title: string;
body: string;
}
export async function generateStaticParams() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts: Post[] = await res.json();
return posts.slice(0, 10).map((post) => ({
slug: post.id.toString(),
}));
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.slug}`
);
if (!res.ok) {
return { title: "פוסט לא נמצא" };
}
const post: Post = await res.json();
return {
title: post.title,
description: post.body.substring(0, 160),
};
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${params.slug}`
);
if (!res.ok) notFound();
const post: Post = await res.json();
return (
<article className="max-w-3xl mx-auto p-6">
<Link href="/blog" className="text-blue-500 hover:underline mb-4 block">
חזרה לרשימה
</Link>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-lg leading-relaxed">{post.body}</div>
</article>
);
}
generateStaticParamsיוצר 10 דפים סטטיים בזמן buildgenerateMetadataמגדיר כותרת ותיאור דינמיים לכל פוסט
פתרון תרגיל 6 - Middleware¶
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// הוספת header עם חותמת זמן לכל בקשה
const response = NextResponse.next();
response.headers.set("x-request-time", new Date().toISOString());
// הפניות
if (pathname === "/home") {
return NextResponse.redirect(new URL("/", request.url));
}
if (pathname === "/blog/old-post") {
return NextResponse.redirect(new URL("/blog/new-post", request.url));
}
// בדיקת API key לנתיבים מוגנים
if (pathname.startsWith("/api/protected")) {
const apiKey = request.headers.get("x-api-key");
if (!apiKey || apiKey !== process.env.API_KEY) {
return NextResponse.json(
{ error: "מפתח API חסר או לא תקין" },
{ status: 401 }
);
}
}
return response;
}
export const config = {
matcher: ["/home", "/blog/old-post", "/api/protected/:path*", "/((?!_next/static|favicon.ico).*)"],
};
תשובות לשאלות¶
-
ההבדל בין [slug], [...slug], ו-[[...slug]]: סוג
[slug]תופס סגמנט בודד (/blog/hello). סוג[...slug]תופס סגמנט אחד או יותר (/docs/a/b/c) אבל לא את הנתיב הריק. סוג[[...slug]]תופס גם את הנתיב הריק (/docs) וגם נתיבים עם סגמנטים. -
generateStaticParams: הפונקציה מחזירה מערך של כל הפרמטרים האפשריים לנתיב דינמי. נקסט בונה דף סטטי לכל אחד בזמן build. נשתמש בו כשהנתונים ידועים מראש ולא משתנים תדיר, כמו בפוסטים בבלוג או דפי מוצרים.
-
Route Handler לעומת Server Action: Route Handlers הם HTTP endpoints סטנדרטיים שתומכים בכל ה-methods (GET, POST, PUT, DELETE). הם מתאימים ל-API ציבורי שצד שלישי צורך. Server Actions הם פונקציות שמיועדות לשינוי נתונים (mutations) מתוך האפליקציה, תומכים ב-Progressive Enhancement, ומשולבים עם מערכת ה-caching.
-
למה אי אפשר route.ts ו-page.tsx באותה תיקייה: שניהם מגיבים לאותו נתיב ויהיה קונפליקט. נקסט לא יודע אם לטפל בבקשה כדף HTML או כ-API response. הפתרון הוא לשים את ה-route.ts בתיקיית api.
-
Middleware לעומת בדיקה ב-Route Handler: Middleware רץ לפני ה-handler ומתאים לבדיקות חוצות-נתיבים כמו אותנטיקציה, הפניות, והוספת headers. בדיקה ב-Route Handler מתאימה ללוגיקה ספציפית לנתיב מסוים. Middleware יעיל יותר כשאותה בדיקה נדרשת במספר נתיבים.