10.3 Technical SEO פתרון
פתרון - SEO טכני - Technical SEO¶
פתרון תרגיל 1¶
מבנה URL מלא:
/ # דף הבית
/news # רשימת חדשות
/news/react-19-released # כתבת חדשות ספציפית
/guides # כל המדריכים
/guides/react # מדריכי React
/guides/react/hooks-complete-guide # מדריך ספציפי
/guides/nodejs # מדריכי Node.js
/reviews # סקירות מוצרים
/reviews/macbook-pro-m4 # סקירה ספציפית
/podcast # כל הפרקים
/podcast/ep-42-future-of-web # פרק ספציפי
/about # אודות
/contact # צור קשר
מבנה תיקיות ב-Next.js:
app/
├── page.tsx # /
├── news/
│ ├── page.tsx # /news
│ └── [slug]/
│ └── page.tsx # /news/[slug]
├── guides/
│ ├── page.tsx # /guides
│ └── [category]/
│ ├── page.tsx # /guides/[category]
│ └── [slug]/
│ └── page.tsx # /guides/[category]/[slug]
├── reviews/
│ ├── page.tsx # /reviews
│ └── [slug]/
│ └── page.tsx # /reviews/[slug]
├── podcast/
│ ├── page.tsx # /podcast
│ └── [slug]/
│ └── page.tsx # /podcast/[slug]
├── about/
│ └── page.tsx # /about
├── contact/
│ └── page.tsx # /contact
├── sitemap.ts
└── robots.ts
הסבר ההחלטות:
- URL-ים קצרים וקריאים עם מילות מפתח רלוונטיות
- היררכיה שטוחה - כל דף נגיש תוך 3 קליקים מדף הבית
- מדריכים מחולקים לפי קטגוריה כי יש הרבה תוכן ומשתמשים מחפשים לפי טכנולוגיה
- חדשות ללא קטגוריה ב-URL כי זה פחות חשוב לחיפוש
- פרקי פודקאסט עם slug תיאורי ולא מספר בלבד
פתרון תרגיל 2¶
// app/sitemap.ts
import { MetadataRoute } from 'next';
const BASE_URL = 'https://store.com';
interface Product {
slug: string;
category: string;
updatedAt: string;
}
interface Category {
slug: string;
updatedAt: string;
}
interface BlogPost {
slug: string;
updatedAt: string;
}
async function getProducts(): Promise<Product[]> {
const res = await fetch(`${BASE_URL}/api/products`);
return res.json();
}
async function getCategories(): Promise<Category[]> {
const res = await fetch(`${BASE_URL}/api/categories`);
return res.json();
}
async function getBlogPosts(): Promise<BlogPost[]> {
const res = await fetch(`${BASE_URL}/api/posts`);
return res.json();
}
export async function generateSitemaps() {
// 4 sitemaps: סטטי, קטגוריות, מוצרים, בלוג
return [
{ id: 'static' },
{ id: 'categories' },
{ id: 'products' },
{ id: 'blog' },
];
}
export default async function sitemap({
id,
}: {
id: string;
}): Promise<MetadataRoute.Sitemap> {
switch (id) {
case 'static':
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${BASE_URL}/about`,
lastModified: new Date('2026-01-01'),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${BASE_URL}/contact`,
lastModified: new Date('2026-01-01'),
changeFrequency: 'yearly',
priority: 0.2,
},
{
url: `${BASE_URL}/terms`,
lastModified: new Date('2026-01-01'),
changeFrequency: 'yearly',
priority: 0.1,
},
];
case 'categories': {
const categories = await getCategories();
return categories.map((cat) => ({
url: `${BASE_URL}/products/${cat.slug}`,
lastModified: new Date(cat.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
}
case 'products': {
const products = await getProducts();
return products.map((product) => ({
url: `${BASE_URL}/products/${product.category}/${product.slug}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
}
case 'blog': {
const posts = await getBlogPosts();
return [
{
url: `${BASE_URL}/blog`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.6,
},
...posts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.5,
})),
];
}
default:
return [];
}
}
Next.js ייצור אוטומטית sitemap index שמצביע על כל ה-sitemaps.
פתרון תרגיל 3¶
קובץ robots.txt:
# בוטים של מנועי חיפוש
User-agent: Googlebot
Allow: /
Allow: /assets/
Disallow: /app/
Disallow: /api/
Disallow: /admin/
Disallow: /search
Disallow: /users/
# בוטים של AI - חסימה מלאה
User-agent: GPTBot
Disallow: /
User-agent: CCBot
Disallow: /
# כל שאר הבוטים
User-agent: *
Allow: /
Allow: /features
Allow: /pricing
Allow: /blog
Disallow: /app/
Disallow: /api/
Disallow: /admin/
Disallow: /search
Disallow: /users/
Disallow: /assets/
Crawl-delay: 2
Sitemap: https://saas-app.com/sitemap.xml
גרסת Next.js:
// app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: 'Googlebot',
allow: ['/', '/assets/'],
disallow: ['/app/', '/api/', '/admin/', '/search', '/users/'],
},
{
userAgent: 'GPTBot',
disallow: '/',
},
{
userAgent: 'CCBot',
disallow: '/',
},
{
userAgent: '*',
allow: ['/', '/features', '/pricing', '/blog'],
disallow: ['/app/', '/api/', '/admin/', '/search', '/users/', '/assets/'],
},
],
sitemap: 'https://saas-app.com/sitemap.xml',
};
}
הערה: Next.js Metadata API לא תומך ב-crawl-delay, אז אם צריך אותו יש ליצור את הקובץ ידנית ב-public/robots.txt.
פתרון תרגיל 4¶
// components/Breadcrumbs.tsx
import Link from 'next/link';
interface BreadcrumbItem {
label: string;
href: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
baseUrl?: string;
}
export function Breadcrumbs({ items, baseUrl = 'https://example.com' }: BreadcrumbsProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
item: `${baseUrl}${item.href}`,
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav aria-label="נתיב ניווט" className="breadcrumbs">
<ol className="breadcrumbs-list">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={item.href} className="breadcrumbs-item">
{isLast ? (
<span aria-current="page" className="breadcrumbs-current">
{item.label}
</span>
) : (
<>
<Link href={item.href} className="breadcrumbs-link">
{item.label}
</Link>
<span className="breadcrumbs-separator" aria-hidden="true">
/
</span>
</>
)}
</li>
);
})}
</ol>
</nav>
</>
);
}
/* styles/breadcrumbs.css */
.breadcrumbs {
padding: 12px 0;
font-size: 14px;
}
.breadcrumbs-list {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0;
padding: 0;
/* RTL - הרשימה מתחילה מימין */
direction: rtl;
}
.breadcrumbs-item {
display: flex;
align-items: center;
}
.breadcrumbs-link {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumbs-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.breadcrumbs-separator {
margin: 0 8px;
color: #9ca3af;
}
.breadcrumbs-current {
color: #6b7280;
font-weight: 500;
}
שימוש:
// דוגמה 1: דף מוצר
<Breadcrumbs
items={[
{ label: 'דף הבית', href: '/' },
{ label: 'מוצרים', href: '/products' },
{ label: 'מחשבים', href: '/products/laptops' },
{ label: 'Dell XPS 15', href: '/products/laptops/dell-xps-15' },
]}
/>
// דוגמה 2: מאמר בלוג
<Breadcrumbs
items={[
{ label: 'דף הבית', href: '/' },
{ label: 'בלוג', href: '/blog' },
{ label: 'React', href: '/blog/category/react' },
{ label: 'מדריך Hooks', href: '/blog/react-hooks-guide' },
]}
/>
// דוגמה 3: שיעור בקורס
<Breadcrumbs
items={[
{ label: 'דף הבית', href: '/' },
{ label: 'קורסים', href: '/courses' },
{ label: 'פיתוח פרונטאנד', href: '/courses/frontend' },
{ label: 'שיעור 5', href: '/courses/frontend/lesson-5' },
]}
/>
פתרון תרגיל 5¶
// scripts/linkAudit.ts
interface LinkReport {
brokenLinks: { url: string; linkedFrom: string; statusCode: number }[];
orphanPages: string[];
redirectChains: { url: string; chain: string[] }[];
badAnchorTexts: { url: string; anchorText: string; linkedFrom: string }[];
}
const BAD_ANCHORS = ['לחצו כאן', 'כאן', 'קראו עוד', 'click here', 'here', 'read more', 'link'];
async function fetchPage(url: string): Promise<{ html: string; status: number } | null> {
try {
const response = await fetch(url, { redirect: 'manual' });
const html = await response.text();
return { html, status: response.status };
} catch {
return null;
}
}
function extractLinks(html: string, baseUrl: string): { href: string; text: string }[] {
const linkRegex = /<a\s+[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi;
const links: { href: string; text: string }[] = [];
let match;
while ((match = linkRegex.exec(html)) !== null) {
const href = match[1];
const text = match[2].replace(/<[^>]*>/g, '').trim();
// רק קישורים פנימיים
if (href.startsWith('/') || href.startsWith(baseUrl)) {
const fullUrl = href.startsWith('/') ? `${baseUrl}${href}` : href;
links.push({ href: fullUrl, text });
}
}
return links;
}
async function followRedirects(url: string): Promise<string[]> {
const chain: string[] = [url];
let currentUrl = url;
for (let i = 0; i < 10; i++) {
const response = await fetch(currentUrl, { redirect: 'manual' });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
currentUrl = location.startsWith('/') ? `${new URL(url).origin}${location}` : location;
chain.push(currentUrl);
} else {
break;
}
} else {
break;
}
}
return chain;
}
export async function auditLinks(baseUrl: string): Promise<LinkReport> {
const report: LinkReport = {
brokenLinks: [],
orphanPages: [],
redirectChains: [],
badAnchorTexts: [],
};
const visited = new Set<string>();
const linkedPages = new Set<string>();
const allPages = new Set<string>();
const queue: string[] = [baseUrl];
while (queue.length > 0) {
const currentUrl = queue.shift()!;
if (visited.has(currentUrl)) continue;
visited.add(currentUrl);
allPages.add(currentUrl);
const result = await fetchPage(currentUrl);
if (!result) continue;
const links = extractLinks(result.html, baseUrl);
for (const link of links) {
linkedPages.add(link.href);
// בדיקת קישורים שבורים
const linkResult = await fetchPage(link.href);
if (!linkResult || linkResult.status === 404) {
report.brokenLinks.push({
url: link.href,
linkedFrom: currentUrl,
statusCode: linkResult?.status || 0,
});
}
// בדיקת anchor text
if (!link.text || BAD_ANCHORS.includes(link.text.toLowerCase())) {
report.badAnchorTexts.push({
url: link.href,
anchorText: link.text || '(ריק)',
linkedFrom: currentUrl,
});
}
// בדיקת שרשרת הפניות
const chain = await followRedirects(link.href);
if (chain.length > 2) {
report.redirectChains.push({ url: link.href, chain });
}
// הוספה לתור
if (!visited.has(link.href) && link.href.startsWith(baseUrl)) {
queue.push(link.href);
}
}
}
// זיהוי דפים יתומים
for (const page of allPages) {
if (page !== baseUrl && !linkedPages.has(page)) {
report.orphanPages.push(page);
}
}
return report;
}
// שימוש
async function main() {
const report = await auditLinks('https://example.com');
console.log('קישורים שבורים:', report.brokenLinks.length);
console.log('דפים יתומים:', report.orphanPages.length);
console.log('שרשרת הפניות:', report.redirectChains.length);
console.log('anchor text בעייתי:', report.badAnchorTexts.length);
}
הערה: הסקריפט פשוט למטרות לימוד. בפרודקשן, כדאי להשתמש בכלים מוכנים כמו Screaming Frog או Ahrefs Site Audit.
פתרון תרגיל 6¶
// app/products/[slug]/page.tsx
import Image from 'next/image';
import Link from 'next/link';
import styles from './product.module.css';
interface Product {
name: string;
description: string;
image: string;
price: number;
relatedProducts: { id: string; name: string }[];
}
export default function ProductPage({ product }: { product: Product }) {
return (
<main className={styles.container}>
<article className={styles.product}>
<div className={styles.imageWrapper}>
<Image
src={product.image}
alt={product.name}
width={600}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
priority
className={styles.productImage}
/>
</div>
<div className={styles.details}>
<h1 className={styles.title}>{product.name}</h1>
<p className={styles.description}>{product.description}</p>
<p className={styles.price}>{product.price} ₪</p>
<button className={styles.buyButton}>
קנה עכשיו
</button>
{product.relatedProducts.length > 0 && (
<section className={styles.related}>
<h2 className={styles.relatedTitle}>מוצרים קשורים</h2>
<ul className={styles.relatedList}>
{product.relatedProducts.map((p) => (
<li key={p.id}>
<Link href={`/products/${p.id}`} className={styles.relatedLink}>
{p.name}
</Link>
</li>
))}
</ul>
</section>
)}
</div>
</article>
</main>
);
}
/* product.module.css */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 16px;
}
.product {
display: flex;
gap: 40px;
}
.imageWrapper {
flex: 1;
}
.productImage {
width: 100%;
height: auto;
border-radius: 8px;
}
.details {
flex: 1;
}
.title {
font-size: 28px;
font-weight: bold;
margin-bottom: 16px;
}
.description {
font-size: 16px;
line-height: 1.6;
color: #4b5563;
margin-bottom: 20px;
}
.price {
font-size: 32px;
font-weight: bold;
color: #059669;
margin-bottom: 24px;
}
.buyButton {
display: block;
width: 100%;
padding: 16px 32px;
font-size: 18px;
font-weight: bold;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
min-height: 48px;
transition: background-color 0.2s;
}
.buyButton:hover {
background-color: #2563eb;
}
.related {
margin-top: 32px;
}
.relatedTitle {
font-size: 20px;
margin-bottom: 12px;
}
.relatedList {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.relatedLink {
display: inline-block;
padding: 10px 16px;
font-size: 14px;
color: #3b82f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
text-decoration: none;
min-height: 44px;
display: flex;
align-items: center;
}
.relatedLink:hover {
background-color: #f3f4f6;
}
/* תצוגת מובייל */
@media (max-width: 768px) {
.product {
flex-direction: column;
gap: 20px;
}
.title {
font-size: 22px;
}
.price {
font-size: 26px;
}
}
שינויים שבוצעו:
- תמונה: עברה ל-next/image עם sizes responsive ו-width/height auto
- טיפוגרפיה: גודל פונט מינימלי 14px (היה 8-10px), H1 בגודל 28px
- כפתור: גובה מינימלי 48px, פונט 18px, רוחב מלא
- קישורים: גובה מינימלי 44px (מינימום לאזור מגע), פונט 14px
- Layout: מעבר ל-column ב-768px ומטה
- סמנטיקה: main, article, section, h2, ul, li
- רוחב: max-width עם padding במקום רוחב קבוע
תשובות לשאלות¶
1. Disallow ב-robots.txt מונע מהבוט לסרוק את הדף, אבל אם יש קישורים חיצוניים אליו, הוא עדיין יכול להופיע באינדקס (ללא תוכן). meta noindex אומר לבוט לא לכלול את הדף באינדקס, גם אם הוא נסרק. משתמשים ב-Disallow כשלא רוצים שהבוט יבזבז זמן על דפים לא חשובים (כמו דפי admin). משתמשים ב-noindex כשרוצים שהדף לא יופיע בתוצאות בשום מצב.
2. Crawl budget הוא מספר הדפים שבוט מנוע חיפוש מוכן לסרוק באתר בפרק זמן נתון. הוא חשוב כי אם האתר גדול והבוט מבזבז את התקציב על דפים לא חשובים (כמו דפי חיפוש או דפי סינון), דפים חשובים עלולים לא להיסרק. robots.txt עוזר לחסום דפים לא חשובים ולהפנות את התקציב לדפים שחשוב לאנדקס.
3. שרשרת הפניות (כשדף A מפנה ל-B שמפנה ל-C שמפנה ל-D) פוגעת ב-SEO כי כל הפניה מאבדת חלק מה-link equity, מגדילה את זמן הטעינה, ומבזבזת crawl budget. הפתרון הוא לוודא שכל הפניה מובילה ישירות ליעד הסופי (A מפנה ישירות ל-D) ולעדכן קישורים פנימיים כך שיצביעו ישירות על ה-URL הנוכחי.
4. Breadcrumbs חשובים ל-SEO כי הם מספקים מבנה היררכי ברור למנועי חיפוש (במיוחד עם JSON-LD), יוצרים קישורים פנימיים נוספים, ומופיעים בתוצאות החיפוש כנתיב ניווט. מבחינת UX, הם עוזרים למשתמש להבין איפה הוא נמצא באתר ולנווט חזרה לדפים גבוהים יותר בהיררכיה.
5. sitemap.xml הוא קובץ XML שמכיל רשימת URL-ים. הוא מוגבל ל-50,000 URL-ים ולגודל של 50MB. sitemap index הוא קובץ XML שמצביע על מספר קבצי sitemap. צריך sitemap index כשהאתר מכיל יותר מ-50,000 URL-ים, או כשרוצים לארגן את ה-sitemaps לפי סוג (מוצרים, בלוג, קטגוריות) לניהול קל יותר.