10.2 - מטא תגיות ומידע מובנה - פתרון
פתרון - מטא tags ומידע מובנה - 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>
{/* Rest of the content */}
</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; // in seconds
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;
// Check title
const titleMatch = html.match(/<title>(.*?)<\/title>/s);
const title = titleMatch ? titleMatch[1].trim() : null;
if (!title) {
issues.push({ severity: 'error', message: 'Missing title tag' });
score -= 15;
} else if (title.length < 30) {
issues.push({ severity: 'warning', message: `Title is too short (${title.length} characters). 50-60 characters recommended.` });
score -= 5;
} else if (title.length > 60) {
issues.push({ severity: 'warning', message: `Title is too long (${title.length} characters). Up to 60 characters recommended.` });
score -= 5;
}
// Check 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: 'Missing meta description tag' });
score -= 15;
} else if (description.length < 70) {
issues.push({ severity: 'warning', message: `Description is too short (${description.length} characters). 120-155 characters recommended.` });
score -= 5;
} else if (description.length > 155) {
issues.push({ severity: 'warning', message: `Description is too long (${description.length} characters). Up to 155 characters recommended.` });
score -= 3;
}
// Check 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: 'Missing og:title tag' });
score -= 5;
}
if (!hasOgDesc) {
issues.push({ severity: 'warning', message: 'Missing og:description tag' });
score -= 5;
}
if (!hasOgImage) {
issues.push({ severity: 'warning', message: 'Missing og:image tag' });
score -= 5;
}
// Check 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: 'Missing twitter:card tag' });
score -= 3;
}
// Check canonical
const hasCanonical = html.includes('rel="canonical"');
if (!hasCanonical) {
issues.push({ severity: 'warning', message: 'Missing canonical URL' });
score -= 10;
}
// Check viewport
const hasViewport = html.includes('name="viewport"');
if (!hasViewport) {
issues.push({ severity: 'error', message: 'Missing viewport tag - the site will not be mobile-friendly' });
score -= 15;
}
// Check 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 exists but is invalid (JSON error)' });
score -= 10;
}
} else {
issues.push({ severity: 'info', message: 'No JSON-LD structured data' });
score -= 5;
}
// The score cannot go below 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(`Score: ${result.score}/100`);
console.log('Issues:', result.issues);
// Score: 52/100
// Issues: [
// { severity: 'warning', message: 'Title is too short...' },
// { severity: 'error', message: 'Missing meta description tag' },
// { severity: 'warning', message: 'Missing og:title tag' },
// ...
// ]
תשובות לשאלות¶
1. הtag title מופיעה בכרטיסיית הדפדפן ובתוצאות החיפוש של Google. הtag 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 אוטומטי (מונע tags כפולות). בנוסף, הוא מטפל אוטומטית ב-metadataBase לנתיבים יחסיים.
5. כשמשתפים קישור בוואטסאפ ללא tags Open Graph, וואטסאפ מנסה לחלץ את ה-title ואת השורה הראשונה של התוכן מה-HTML. התצוגה המקדימה תהיה בסיסית מאוד, בדרך כלל רק ה-URL עצמו או כותרת הדף ללא תמונה. זה פוגע בשיעור הקליקים כי התצוגה לא מושכת.