10.2 מטא תגיות ומידע מובנה פתרון
פתרון - מטא תגיות ומידע מובנה - Meta Tags and Structured Data¶
פתרון תרגיל 1¶
// app/products/[slug]/page.tsx
import { Metadata } from 'next';
import Image from 'next/image';
interface Product {
name: string;
slug: string;
description: string;
price: number;
image: string;
brand: string;
}
async function getProduct(slug: string): Promise<Product> {
const res = await fetch(`https://api.store.com/products/${slug}`);
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const product = await getProduct(params.slug);
return {
title: `${product.name} | חנות האלקטרוניקה`,
description: `${product.description} - ${product.brand}. מחיר: ${product.price} ₪. משלוח חינם.`,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
url: `https://store.com/products/${product.slug}`,
siteName: 'חנות האלקטרוניקה',
locale: 'he_IL',
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
],
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: `${product.description} - ₪${product.price}`,
images: [product.image],
},
alternates: {
canonical: `https://store.com/products/${product.slug}`,
languages: {
'he-IL': `https://store.com/he/products/${product.slug}`,
'en-US': `https://store.com/en/products/${product.slug}`,
},
},
};
}
export default async function ProductPage({
params,
}: {
params: { slug: string };
}) {
const product = await getProduct(params.slug);
return (
<main>
<h1>{product.name}</h1>
<Image src={product.image} alt={product.name} width={600} height={400} />
<p>{product.description}</p>
<p>
<strong>₪{product.price}</strong>
</p>
</main>
);
}
פתרון תרגיל 2¶
// components/ProductJsonLd.tsx
interface ProductReview {
author: string;
rating: number;
text: string;
date: string;
}
interface Product {
name: string;
description: string;
image: string;
brand: string;
price: number;
currency: string;
inStock: boolean;
rating: number;
reviewCount: number;
reviews: ProductReview[];
}
export function ProductJsonLd({ product }: { product: Product }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.image,
brand: {
'@type': 'Brand',
name: product.brand,
},
offers: {
'@type': 'Offer',
url: `https://store.com/products/${encodeURIComponent(product.name)}`,
priceCurrency: product.currency,
price: product.price.toString(),
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
seller: {
'@type': 'Organization',
name: 'חנות האלקטרוניקה',
},
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.rating.toString(),
reviewCount: product.reviewCount.toString(),
bestRating: '5',
worstRating: '1',
},
review: product.reviews.map((review) => ({
'@type': 'Review',
author: {
'@type': 'Person',
name: review.author,
},
reviewRating: {
'@type': 'Rating',
ratingValue: review.rating.toString(),
bestRating: '5',
worstRating: '1',
},
reviewBody: review.text,
datePublished: review.date,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
שימוש בקומפוננטה:
// app/products/[slug]/page.tsx
import { ProductJsonLd } from '@/components/ProductJsonLd';
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
return (
<>
<ProductJsonLd product={product} />
<main>
<h1>{product.name}</h1>
{/* שאר התוכן */}
</main>
</>
);
}
פתרון תרגיל 3¶
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://techblog.co.il'),
title: {
default: 'TechBlog - בלוג טכנולוגי בעברית',
template: '%s | TechBlog',
},
description: 'מאמרים, מדריכים וחדשות מעולם הטכנולוגיה והפיתוח בעברית',
openGraph: {
siteName: 'TechBlog',
locale: 'he_IL',
type: 'website',
images: [
{
url: '/og-default.jpg',
width: 1200,
height: 630,
alt: 'TechBlog',
},
],
},
twitter: {
card: 'summary_large_image',
site: '@techblog_il',
},
robots: {
index: true,
follow: true,
},
verification: {
google: 'google-verification-code',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="he" dir="rtl">
<body>{children}</body>
</html>
);
}
// app/blog/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'בלוג',
description: 'כל המאמרים והמדריכים שלנו בנושאי פיתוח, טכנולוגיה ו-DevOps',
alternates: {
canonical: '/blog',
},
};
interface PostSummary {
slug: string;
title: string;
excerpt: string;
publishedAt: string;
}
async function getPosts(): Promise<PostSummary[]> {
const res = await fetch('https://api.techblog.co.il/posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<main>
<h1>הבלוג שלנו</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('he-IL')}
</time>
</a>
</li>
))}
</ul>
</main>
);
}
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
interface BlogPost {
slug: string;
title: string;
excerpt: string;
content: string;
coverImage: string;
author: { name: string; url: string };
publishedAt: string;
updatedAt: string;
tags: string[];
}
async function getPost(slug: string): Promise<BlogPost> {
const res = await fetch(`https://api.techblog.co.il/posts/${slug}`);
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `/blog/${post.slug}`,
},
};
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: post.author.url,
},
publisher: {
'@type': 'Organization',
name: 'TechBlog',
logo: {
'@type': 'ImageObject',
url: 'https://techblog.co.il/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://techblog.co.il/blog/${post.slug}`,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
פתרון תרגיל 4¶
// app/episodes/[slug]/page.tsx
import { Metadata } from 'next';
interface Episode {
slug: string;
title: string;
description: string;
coverImage: string;
audioUrl: string;
duration: number; // בשניות
publishedAt: string;
host: string;
}
async function getEpisode(slug: string): Promise<Episode> {
const res = await fetch(`https://api.podcast.co.il/episodes/${slug}`);
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const episode = await getEpisode(params.slug);
return {
title: `${episode.title} | הפודקאסט שלנו`,
description: episode.description,
openGraph: {
title: episode.title,
description: episode.description,
type: 'music.song',
url: `https://podcast.co.il/episodes/${episode.slug}`,
siteName: 'הפודקאסט שלנו',
locale: 'he_IL',
images: [
{
url: episode.coverImage,
width: 1200,
height: 630,
alt: `עטיפת פרק: ${episode.title}`,
},
],
audio: [
{
url: episode.audioUrl,
type: 'audio/mpeg',
},
],
},
twitter: {
card: 'player',
title: episode.title,
description: episode.description,
images: [episode.coverImage],
players: [
{
playerUrl: `https://podcast.co.il/embed/${episode.slug}`,
streamUrl: episode.audioUrl,
width: 480,
height: 270,
},
],
},
alternates: {
canonical: `https://podcast.co.il/episodes/${episode.slug}`,
},
};
}
export default async function EpisodePage({
params,
}: {
params: { slug: string };
}) {
const episode = await getEpisode(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'PodcastEpisode',
name: episode.title,
description: episode.description,
datePublished: episode.publishedAt,
duration: `PT${Math.floor(episode.duration / 60)}M${episode.duration % 60}S`,
associatedMedia: {
'@type': 'MediaObject',
contentUrl: episode.audioUrl,
},
partOfSeries: {
'@type': 'PodcastSeries',
name: 'הפודקאסט שלנו',
url: 'https://podcast.co.il',
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<main>
<h1>{episode.title}</h1>
<audio controls src={episode.audioUrl}>
הדפדפן שלך לא תומך בנגן אודיו.
</audio>
<p>{episode.description}</p>
</main>
</>
);
}
יצירת תמונת OG דינמית:
// app/episodes/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'תמונת פרק פודקאסט';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
async function getEpisode(slug: string) {
const res = await fetch(`https://api.podcast.co.il/episodes/${slug}`);
return res.json();
}
export default async function OGImage({
params,
}: {
params: { slug: string };
}) {
const episode = await getEpisode(params.slug);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1a1a2e',
color: 'white',
fontFamily: 'sans-serif',
padding: '40px',
}}
>
<div style={{ fontSize: '24px', color: '#e94560', marginBottom: '20px' }}>
הפודקאסט שלנו
</div>
<div
style={{
fontSize: '48px',
fontWeight: 'bold',
textAlign: 'center',
marginBottom: '20px',
}}
>
{episode.title}
</div>
<div style={{ fontSize: '20px', color: '#aaa' }}>
{`${Math.floor(episode.duration / 60)} דקות`}
</div>
</div>
),
{ ...size }
);
}
פתרון תרגיל 5¶
// app/faq/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'שאלות נפוצות',
description: 'תשובות לשאלות הנפוצות ביותר על משלוחים, החזרות, תשלומים ושירות לקוחות.',
alternates: {
canonical: '/faq',
},
};
interface FaqItem {
question: string;
answer: string;
}
const faqData: FaqItem[] = [
{
question: 'כמה זמן לוקח משלוח?',
answer:
'משלוח רגיל מגיע תוך 3-5 ימי עסקים. משלוח אקספרס מגיע תוך יום עסקים אחד. משלוח חינם בהזמנות מעל 200 שקלים.',
},
{
question: 'מהי מדיניות ההחזרות?',
answer:
'ניתן להחזיר מוצרים תוך 14 יום מיום הקבלה. המוצר חייב להיות באריזה המקורית ובמצב חדש. ההחזר הכספי יתבצע תוך 5 ימי עסקים.',
},
{
question: 'אילו אמצעי תשלום מקבלים?',
answer:
'אנחנו מקבלים כרטיסי אשראי (ויזה, מסטרכארד, אמריקן אקספרס), PayPal, Apple Pay, ותשלומים בביט.',
},
{
question: 'איך אפשר לעקוב אחרי ההזמנה?',
answer:
'לאחר המשלוח תקבלו מייל עם מספר מעקב. ניתן לעקוב אחרי ההזמנה באזור האישי באתר או דרך הקישור שנשלח במייל.',
},
{
question: 'האם אפשר לשנות או לבטל הזמנה?',
answer:
'ניתן לשנות או לבטל הזמנה תוך שעה מרגע ביצועה, לפני שהיא נשלחת מהמחסן. לאחר מכן, יש לבצע החזרה רגילה.',
},
];
function FaqJsonLd({ items }: { items: FaqItem[] }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
function FaqAccordion({ items }: { items: FaqItem[] }) {
return (
<div className="faq-list">
{items.map((item, index) => (
<details key={index} className="faq-item">
<summary className="faq-question">
<h2>{item.question}</h2>
</summary>
<div className="faq-answer">
<p>{item.answer}</p>
</div>
</details>
))}
</div>
);
}
export default function FaqPage() {
return (
<>
<FaqJsonLd items={faqData} />
<main>
<h1>שאלות נפוצות</h1>
<p>מצאו תשובות לשאלות הנפוצות ביותר על שירות הלקוחות שלנו.</p>
<FaqAccordion items={faqData} />
</main>
</>
);
}
- שימו לב שאותו מערך
faqDataמשמש גם את ה-UI (האקורדיון) וגם את ה-JSON-LD - זה מבטיח סנכרון בין התוכן המוצג לבין המידע המובנה
- שימוש ב-
<details>ו-<summary>מספק אקורדיון נגיש ללא JavaScript
פתרון תרגיל 6¶
// utils/metaAudit.ts
interface AuditResult {
score: number;
issues: AuditIssue[];
summary: {
title: string | null;
description: string | null;
hasOg: boolean;
hasTwitter: boolean;
hasCanonical: boolean;
hasViewport: boolean;
hasJsonLd: boolean;
};
}
interface AuditIssue {
severity: 'error' | 'warning' | 'info';
message: string;
}
export function auditMetaTags(html: string): AuditResult {
const issues: AuditIssue[] = [];
let score = 100;
// בדיקת title
const titleMatch = html.match(/<title>(.*?)<\/title>/s);
const title = titleMatch ? titleMatch[1].trim() : null;
if (!title) {
issues.push({ severity: 'error', message: 'חסרה תגית title' });
score -= 15;
} else if (title.length < 30) {
issues.push({ severity: 'warning', message: `הכותרת קצרה מדי (${title.length} תווים). מומלץ 50-60 תווים.` });
score -= 5;
} else if (title.length > 60) {
issues.push({ severity: 'warning', message: `הכותרת ארוכה מדי (${title.length} תווים). מומלץ עד 60 תווים.` });
score -= 5;
}
// בדיקת meta description
const descMatch = html.match(/<meta\s+name="description"\s+content="(.*?)"/s);
const description = descMatch ? descMatch[1].trim() : null;
if (!description) {
issues.push({ severity: 'error', message: 'חסרה תגית meta description' });
score -= 15;
} else if (description.length < 70) {
issues.push({ severity: 'warning', message: `התיאור קצר מדי (${description.length} תווים). מומלץ 120-155 תווים.` });
score -= 5;
} else if (description.length > 155) {
issues.push({ severity: 'warning', message: `התיאור ארוך מדי (${description.length} תווים). מומלץ עד 155 תווים.` });
score -= 3;
}
// בדיקת Open Graph
const hasOgTitle = html.includes('og:title');
const hasOgDesc = html.includes('og:description');
const hasOgImage = html.includes('og:image');
const hasOg = hasOgTitle && hasOgDesc && hasOgImage;
if (!hasOgTitle) {
issues.push({ severity: 'warning', message: 'חסרה תגית og:title' });
score -= 5;
}
if (!hasOgDesc) {
issues.push({ severity: 'warning', message: 'חסרה תגית og:description' });
score -= 5;
}
if (!hasOgImage) {
issues.push({ severity: 'warning', message: 'חסרה תגית og:image' });
score -= 5;
}
// בדיקת Twitter Cards
const hasTwitterCard = html.includes('twitter:card');
const hasTwitterTitle = html.includes('twitter:title');
const hasTwitter = hasTwitterCard && hasTwitterTitle;
if (!hasTwitterCard) {
issues.push({ severity: 'info', message: 'חסרה תגית twitter:card' });
score -= 3;
}
// בדיקת canonical
const hasCanonical = html.includes('rel="canonical"');
if (!hasCanonical) {
issues.push({ severity: 'warning', message: 'חסרה כתובת canonical' });
score -= 10;
}
// בדיקת viewport
const hasViewport = html.includes('name="viewport"');
if (!hasViewport) {
issues.push({ severity: 'error', message: 'חסרה תגית viewport - האתר לא יהיה mobile-friendly' });
score -= 15;
}
// בדיקת JSON-LD
const jsonLdMatch = html.match(/<script\s+type="application\/ld\+json">(.*?)<\/script>/s);
let hasJsonLd = false;
if (jsonLdMatch) {
try {
JSON.parse(jsonLdMatch[1]);
hasJsonLd = true;
} catch {
issues.push({ severity: 'error', message: 'JSON-LD קיים אבל לא תקין (שגיאת JSON)' });
score -= 10;
}
} else {
issues.push({ severity: 'info', message: 'אין JSON-LD structured data' });
score -= 5;
}
// הציון לא יכול לרדת מתחת ל-0
score = Math.max(0, score);
return {
score,
issues,
summary: {
title,
description,
hasOg,
hasTwitter,
hasCanonical,
hasViewport,
hasJsonLd,
},
};
}
שימוש בפונקציה:
const html = `
<!DOCTYPE html>
<html lang="he">
<head>
<meta charset="UTF-8" />
<title>מדריך React</title>
</head>
<body><h1>שלום</h1></body>
</html>
`;
const result = auditMetaTags(html);
console.log(`ציון: ${result.score}/100`);
console.log('בעיות:', result.issues);
// ציון: 52/100
// בעיות: [
// { severity: 'warning', message: 'הכותרת קצרה מדי...' },
// { severity: 'error', message: 'חסרה תגית meta description' },
// { severity: 'warning', message: 'חסרה תגית og:title' },
// ...
// ]
תשובות לשאלות¶
1. תגית title מופיעה בכרטיסיית הדפדפן ובתוצאות החיפוש של Google. תגית og:title משמשת כשמשתפים את הקישור ברשתות חברתיות. הן לא חייבות להיות זהות - לפעמים כדאי שה-og:title תהיה קצרה ותמציתית יותר, בעוד ה-title כוללת גם את שם האתר.
2. כתובת canonical חשובה כדי למנוע בעיות תוכן כפול. שלוש סיטואציות: (1) כשיש פרמטרים ב-URL כמו ?sort=price&color=red - כולם מצביעים על אותו canonical ללא הפרמטרים. (2) כשהאתר נגיש גם עם www וגם בלי, או גם ב-HTTP וגם ב-HTTPS. (3) כשיש pagination - כל דף תוצאות צריך canonical של עצמו.
3. Rich Results הם תוצאות חיפוש מועשרות שמציגות מידע נוסף מעבר לכותרת ותיאור. JSON-LD מאפשר ל-Google להבין את המבנה של הנתונים. דוגמאות: (1) דף מתכון עם סכמת Recipe מציג כוכבי דירוג, זמן הכנה ותמונה בתוצאות. (2) דף מוצר עם סכמת Product מציג מחיר וזמינות.
4. Metadata API של Next.js מספק type safety עם TypeScript, ירושה אוטומטית של מטא-דאטה מ-layout לדפים, תמיכה מובנית במטא-דאטה דינמית עם generateMetadata, ו-deduplication אוטומטי (מונע תגיות כפולות). בנוסף, הוא מטפל אוטומטית ב-metadataBase לנתיבים יחסיים.
5. כשמשתפים קישור בוואטסאפ ללא תגיות Open Graph, וואטסאפ מנסה לחלץ את ה-title ואת השורה הראשונה של התוכן מה-HTML. התצוגה המקדימה תהיה בסיסית מאוד, בדרך כלל רק ה-URL עצמו או כותרת הדף ללא תמונה. זה פוגע בשיעור הקליקים כי התצוגה לא מושכת.