এই পর্বে যা শিখবেন
- Crawler একটি Next.js পাতা কীভাবে পড়ে, আর rendering-এর পছন্দ তাতে কী বদলায়
- Static
metadata,generateMetadata, title template, canonical, hreflang আর robots লিখেnext build-এর আসল HTML যাচাই করা sitemap.tsআরrobots.tsদিয়ে ফাইল বানানো, XML-এর escaping আর ফাইল ভাগ করার হিসাব- Product আর BreadcrumbList-এর JSON-LD লেখা, schema.org-এর সংজ্ঞার সাথে মেলানো
- Open Graph, Twitter card আর
next/og-এর ছবি, বাংলা ফন্টের সমস্যাসহ - Filter-এর URL, খোঁজার পাতা, 404 আর soft 404 সামলানো, আর পণ্যের পাতার একটি তালিকা
দামের একক নিয়ে: এই পর্বের ছোট ল্যাবে উদাহরণ সহজ রাখতে দাম পূর্ণ টাকায় রাখা হয়েছে, আর আউটপুটগুলোও সেই run-এর। আসল দোকানে টাকা রাখুন পয়সায়, পূর্ণসংখ্যা হিসেবে, যেমনটি পর্ব ৩২-এ দেখেছেন।
১ · Crawler কী দেখে — rendering আর SEO
“বাজারঘর”-এর মিনিকেট চালের পাতা তৈরি। কেউ Google-এ “মিনিকেট চাল ৫ কেজি দাম” লিখলেন। পাতাটি কি সেখানে আসবে? কেউ পাতার লিংক WhatsApp-এ পাঠালেন। সুন্দর একটি কার্ড আসবে, নাকি শুধু খালি লিংক? এই দুই প্রশ্নের উত্তরই ঠিক হয় পাতার প্রথম HTML-এ, যা আপনার server পাঠায়।
মেলার আগে একজন পরিদর্শক প্রতিটি দোকানে ঘোরেন আর একটি তালিকা বানান। তিনি দেখেন দোকানের সাইনবোর্ড, দরজায় “প্রবেশ নিষেধ” লেখা আছে কি না, আর তাকে সাজানো পণ্যের নাম ও দামের লেবেল। দোকানের ভেতরে আলো না জ্বললে তিনি হয়তো পরে আবার আসবেন, হয়তো আসবেন না। Crawler ঠিক এই পরিদর্শক। <title> হলো সাইনবোর্ড, robots meta দরজার নোটিশ, JSON-LD পণ্যের লেবেল, আর JavaScript দিয়ে পরে জ্বলা আলো হলো client-এ তৈরি লেখা।
HTML-এর <head>-এর মূল জিনিস, যেমন title, description, canonical, Open Graph, আগেই শিখেছেন HTML & CSS কোর্সের head আর SEO পর্বে। এখানে আবার শেখানো হবে না। এই পর্বের প্রশ্ন অন্য: হাজার পণ্যের পাতায় এগুলো Next.js দিয়ে ডেটা থেকে কীভাবে বানাবেন, আর বানানোর পর কীভাবে প্রমাণ করবেন যে ঠিক হয়েছে।
পর্ব ৩-এ দেখেছেন, CSR পাতা JavaScript ছাড়া প্রায় ফাঁকা। Google-এর নথি অনুযায়ী Googlebot পাতা আনে, তারপর একটি সারিতে রেখে headless Chromium-এ JavaScript চালিয়ে render করে, তারপর index করে। নথিটি তবু বলে, server-side rendering বা pre-rendering এখনো ভালো ধারণা, কারণ এতে ব্যবহারকারী আর crawler দুজনেরই গতি বাড়ে, আর সব bot JavaScript চালাতে পারে না। অন্য search engine, link preview-এর bot বা AI-এর crawler কতটা JavaScript চালায়, তা প্রত্যেকের নিজের নীতি, আর বদলাতে পারে। তাই নিরাপদ নিয়ম একটিই: যা খুঁজে পাওয়া দরকার, তা প্রথম HTML-এ রাখুন। SSR, SSG আর ISR-এর কোনটি কখন, তা আসবে পর্ব ৫৫-এ।
একটি পণ্যের URL search engine-এর ভেতর দিয়ে
From URL discovery to the index
Next.js এখানে একটি সুবিধা দেয়। Metadata তৈরি হয় server-এ, Server Component-এ, তাই প্রথম HTML-এ থাকে। Next.js ১৫.২ থেকে আরেকটি আচরণ এসেছে: streaming metadata. generateMetadata ধীর হলে Next.js আগে পাতার কাঠামো পাঠায়, পরে metadata <body>-এ জুড়ে দেয়। Docs বলে, যে bot পুরো DOM দেখে (যেমন Googlebot) সে এটি ঠিক পড়ে। আর যে bot JavaScript চালায় না, যেমন facebookexternalhit, তাকে Next.js User-Agent দেখে চেনে আর তার জন্য metadata <head>-এই রাখে। এই তালিকা বদলানো যায় htmlLimitedBots option দিয়ে। এমন bot-কে বলে HTML-limited bot.
আমাদের ল্যাবে /category/rice?brand=x&sort=price একটি dynamic পাতা। সাধারণ curl আর facebookexternalhit/1.1 User-Agent, দুটিতেই title আর robots ট্যাগ <head>-এ এসেছে, কারণ আমাদের generateMetadata সাথে সাথে শেষ হয়। Database ধীর হলে সাধারণ browser-এর জন্য এটি বদলাতে পারে। তাই যাচাইয়ের সময় দুই রকম User-Agent দিয়েই দেখবেন।
ল্যাবের পরিচয়: এই পর্বের সব Next.js আউটপুট একটি ছোট আসল প্রকল্পের, যেখানে পণ্যগুলো একটি in-memory module-এ। লেখার সময়ের version: Next.js 16.4.4 (Turbopack), React 19.3.1, TypeScript 6.0.3, Node 24.15.0, npm 11.12.1, OG ছবির তুলনায় Edge 156 আর Playwright 1.63. প্রকল্পে কোনো next.config.ts নেই, অর্থাৎ Cache Components বন্ধ; এই পর্বে “ডিফল্ট আচরণ” বললে এই config বোঝাবে। create-next-app 16.4.4 --yes দিয়ে বানানো প্রকল্পে cacheComponents: true চালু থাকে (পর্ব ৫১-এর run), আর তাতে একটি জায়গায় ফল বদলায়; ধাপ ৮-এ সেই দ্বিতীয় run দেখবেন। ডোমেইন bazarghar.example কাল্পনিক। প্রকল্পের পথ ছেঁটে ~/bazarghar লেখা হয়েছে। এগুলো বদলায়, তাই নিজের package.json মিলিয়ে নেবেন।
২ · Metadata API — layout-এ নিয়ম, title template আর merge
Next.js-এর App Router-এ <head> হাতে লেখা হয় না। লেখা হয় একটি Metadata API দিয়ে: layout.tsx বা page.tsx থেকে একটি metadata object বা generateMetadata function export করবেন, Next.js সেটি পড়ে ট্যাগ বানাবে। পুরোনো tutorial-এ next/head বা next-seo package দেখবেন; সেগুলো মূলত Pages Router-এর যুগের পথ। Docs অনুযায়ী এই export শুধু Server Component-এ চলে, আর একই segment-এ metadata আর generateMetadata দুটি একসাথে রাখা যায় না।
বাজারঘরের root layout. এখানে পুরো সাইটের নিয়ম:
import type { Metadata } from 'next';
import { SITE } from '@/lib/products'; // 'https://bazarghar.example'
export const metadata: Metadata = {
metadataBase: new URL(SITE),
title: {
template: '%s | বাজারঘর',
default: 'বাজারঘর — ঘরে বসে বাজার',
},
description: 'চাল, ডাল, তেল আর নিত্যপণ্য অনলাইনে অর্ডার করুন।',
openGraph: { siteName: 'বাজারঘর', locale: 'bn_BD', type: 'website' },
twitter: { card: 'summary_large_image' },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="bn">
<body>{children}</body>
</html>
);
}তিনটি নিয়ম মনে রাখুন, সবই Next.js-এর docs আর আমাদের run থেকে:
title.template-এর%s-এ child পাতার title বসে। Template শুধু child segment-এ খাটে, যে layout-এ লেখা সেখানে নয়। তাইdefaultদিতে হয়। কোনো পাতা template এড়াতে চাইলেtitle: { absolute: '…' }.metadataBaseথাকলে canonical বা ছবির URL আপেক্ষিক (/products/…) লেখা যায়, Next.js পুরো URL বানায়। না থাকলে আপেক্ষিক URL-এ build error.- Merge অগভীর (shallow). Layout থেকে পাতা পর্যন্ত object মেশে, কিন্তু একই key আবার এলে পুরো মান বদলে যায়। পাতা
openGraphদিলে layout-এরopenGraph.siteNameহারিয়ে যায়। তাই পণ্যের পাতায় আমরাsiteNameআবার লিখেছি।
এবার দেখুন, একটি পণ্যের ডেটা কীভাবে ধাপে ধাপে <head> হয়ে যায়। প্রতিটি মান ল্যাবের আসল run-এর।
- ডেটা
getProductএকটি পণ্য - পাতা
generateMetadataআপেক্ষিক URL - Merge
layouttemplate, base - HTML
<head>প্রথম response
{ slug: 'miniket-chal-5kg',
nameBn: 'মিনিকেট চাল ৫ কেজি',
descBn: 'ঝরঝরে মিনিকেট চাল, ৫ কেজির প্যাকেট। …',
priceBdt: 450, inStock: true }
title: 'মিনিকেট চাল ৫ কেজি'
alternates.canonical: '/products/miniket-chal-5kg'
alternates.languages: { 'bn-BD', en, 'x-default' }
openGraph: { title, description, url, siteName … }
'%s | বাজারঘর' + 'মিনিকেট চাল ৫ কেজি'
→ মিনিকেট চাল ৫ কেজি | বাজারঘর
og:title-এ template বসে না: 'মিনিকেট চাল ৫ কেজি'
'/products/miniket-chal-5kg'
→ https://bazarghar.example/products/miniket-chal-5kg
'/en/products/…' → https://bazarghar.example/en/products/…
<title>মিনিকেট চাল ৫ কেজি | বাজারঘর</title> <link rel="canonical" href="https://bazarghar.example/products/miniket-chal-5kg"/> <link rel="alternate" hrefLang="en" href="…/en/products/miniket-chal-5kg"/> <meta property="og:image" content="…/opengraph-image?b91b43274e5c72e8"/>
- ডেটা আসে। পণ্যের পাতা
slugদিয়ে একটি সারি আনে। নাম আর বিবরণ বাংলায়, দাম টাকায়। ডেটা ভুল হলে head-ও ভুল হবে, তাই উৎস একটিই রাখুন। generateMetadataobject ফেরত দেয়। URL-গুলো এখনো আপেক্ষিক, যেমন/products/miniket-chal-5kg. এখানে শুধু এই পাতার নিজস্ব তথ্য।- Layout-এর template মেশে।
%s-এর জায়গায় পণ্যের নাম বসে: “মিনিকেট চাল ৫ কেজি | বাজারঘর”। কিন্তুog:title-এ template খাটে না, সেখানে শুধু নাম। metadataBaseURL পূর্ণ করে। প্রতিটি আপেক্ষিক পথের আগেhttps://bazarghar.exampleবসে। Canonical আর hreflang-এ পূর্ণ URL থাকাই নিরাপদ।- HTML-এ ট্যাগ।
curlদিয়ে আনা আসল<head>.og:imageনিজে লিখিনি; পাশেরopengraph-image.tsxফাইল দেখে Next.js একটি hash-সহ URL বসিয়েছে। ধাপ ৭-এ আসবে।
৩ · generateMetadata — async params, canonical আর hreflang
প্রতিটি পণ্যের পাতা আলাদা, তাই সেখানে static object নয়, generateMetadata. Next.js 15 থেকে params আর searchParams একটি Promise, আর Next.js 16-এ পুরোনো synchronous পথটি আর চলে না। তাই await params লিখতে হবে। ২০২৪-এর আগের tutorial-এ params.slug সরাসরি দেখলে বুঝবেন সেটি পুরোনো।
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getProduct, products } from '@/lib/products';
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return products.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const p = await getProduct(slug);
if (!p) notFound();
return {
title: p.nameBn,
description: p.descBn,
alternates: {
canonical: `/products/${p.slug}`,
languages: {
'bn-BD': `/products/${p.slug}`,
en: `/en/products/${p.slug}`,
'x-default': `/products/${p.slug}`,
},
},
openGraph: {
siteName: 'বাজারঘর', locale: 'bn_BD', type: 'website',
title: p.nameBn, description: p.descBn, url: `/products/${p.slug}`,
},
};
}এখানে কয়েকটি সিদ্ধান্ত আছে।
- একই ডেটা দুবার আনা কি খরচ?
generateMetadataআর পাতা দুজনেইgetProductডাকে। Docs বলে, একইfetchএই দুই জায়গায় memoize হয়;fetchনা হলে React-এরcacheদিয়ে মুড়ে দিতে পারেন। ডেটা আনা আর cache-এর পুরো আলোচনা পর্ব ৫৩-এ। - পণ্য না থাকলে
notFound(). এটিgenerateMetadata-এর ভেতরেও চলে, আর server আসল 404 দেয়। ধাপ ৮-এ দেখবেন কেন এটি জরুরি। - Canonical মানে “এই বিষয়বস্তুর আসল ঠিকানা এটি”। একই পণ্য
?utm_source=fbবা?ref=homeদিয়েও খোলে, কিন্তু canonical থাকে একটিই, query ছাড়া। Google-এর নথিতে canonical একটি জোরালো সংকেত, কিন্তু আদেশ নয়; Google নিজে অন্য URL বেছে নিতে পারে। - hreflang বলে, একই পাতার কোন ভাষার রূপ কোথায়।
bn-BDমানে বাংলাদেশের বাংলা,enইংরেজি, আর x-default হলো ভাষা না মিললে কোনটি দেখাতে হবে। Google-এর নথি অনুযায়ী প্রতিটি রূপকে অন্যগুলোর দিকে ফিরতি লিংক দিতে হয়, নিজেরটিসহ। না হলে সংকেত উপেক্ষিত হতে পারে। বাংলা আর ইংরেজি route কীভাবে সাজাবেন, তা পর্ব ৫৯-এ.
next build-এর আসল route table (সত্যিকারের run, Next.js 16.4.4, পথ ছাঁটা):
~/bazarghar$ npx next build
▲ Next.js 16.4.4 (Turbopack)
✓ Compiled successfully in 549ms
Finished TypeScript in 1124ms ...
✓ Generating static pages using 14 workers (14/14) in 523ms
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /category/[cat]
├ /en/products/[slug]
│ ├ ● /en/products/miniket-chal-5kg
│ ├ ● /en/products/moshur-dal-1kg
│ └ ● /en/products/soybean-tel-2l
├ ƒ /og-nofont
├ ƒ /og-test
├ /products/[slug]
│ ├ ● /products/miniket-chal-5kg
│ ├ ● /products/moshur-dal-1kg
│ └ ● /products/soybean-tel-2l
├ ƒ /products/[slug]/opengraph-image
├ ○ /robots.txt
├ ƒ /search
├ ○ /sitemap.xml
└ ƒ /soft404/[slug]
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
পণ্যের পাতা ●, অর্থাৎ build-এর সময়েই HTML আর metadata তৈরি। robots.txt আর sitemap.xml ○, static ফাইল। /search আর /category/[cat] ƒ, কারণ এরা searchParams পড়ে। এবার next start চালিয়ে curl-এ আসল head (সত্যিকারের run, charset আর viewport বাদ):
~/bazarghar$ curl -s http://127.0.0.1:47658/products/miniket-chal-5kg
<title>মিনিকেট চাল ৫ কেজি | বাজারঘর</title>
<meta name="description" content="ঝরঝরে মিনিকেট চাল, ৫ কেজির প্যাকেট। ঢাকার ভেতরে পরের দিন ডেলিভারি।"/>
<link rel="canonical" href="https://bazarghar.example/products/miniket-chal-5kg"/>
<link rel="alternate" hrefLang="bn-BD" href="https://bazarghar.example/products/miniket-chal-5kg"/>
<link rel="alternate" hrefLang="en" href="https://bazarghar.example/en/products/miniket-chal-5kg"/>
<link rel="alternate" hrefLang="x-default" href="https://bazarghar.example/products/miniket-chal-5kg"/>
<meta property="og:title" content="মিনিকেট চাল ৫ কেজি"/>
<meta property="og:url" content="https://bazarghar.example/products/miniket-chal-5kg"/>
<meta property="og:site_name" content="বাজারঘর"/>
<meta property="og:locale" content="bn_BD"/>
<meta property="og:image" content="https://bazarghar.example/products/miniket-chal-5kg/opengraph-image?b91b43274e5c72e8"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="মিনিকেট চাল ৫ কেজি"/>
…
HTML-এ hrefLang লেখা বড় হাতের L দিয়ে; HTML attribute-এর নামে বড়-ছোট হাতের পার্থক্য নেই, তাই এতে সমস্যা নেই। এবার ইংরেজি পাতা। আসল run-এ একটি ভুল ধরা পড়ল:
~/bazarghar$ curl -s http://127.0.0.1:47658/en/products/miniket-chal-5kg
<title>Miniket Rice 5 kg | Bazarghar</title>
<meta name="description" content="চাল, ডাল, তেল আর নিত্যপণ্য অনলাইনে অর্ডার করুন।"/>
<link rel="canonical" href="https://bazarghar.example/en/products/miniket-chal-5kg"/>
<link rel="alternate" hrefLang="bn-BD" href="https://bazarghar.example/products/miniket-chal-5kg"/>
…
শিরোনাম ইংরেজি (title.absolute দিয়ে template এড়ানো), canonical নিজের দিকে, hreflang ঠিক। কিন্তু description এসেছে root layout থেকে, বাংলায়। ইংরেজি পাতা কোনো description দেয়নি, তাই merge-এর নিয়মে layout-এরটি থেকে গেছে। এ ধরনের ভুল চোখে পড়ে শুধু আসল HTML দেখলে। সমাধান: প্রতিটি locale-এর পাতায় নিজের description, অথবা locale-ভিত্তিক layout. পর্ব ৫৯-এ এর সাজানো পথ আসবে।
৪ · বাংলা শিরোনাম আর বিবরণের দৈর্ঘ্য — grapheme গোনা
Search ফলাফলে শিরোনাম লম্বা হলে কেটে যায়। কতটুকু দেখাবে, তা search engine ঠিক করে, সাধারণত পর্দায় পিক্সেলের প্রস্থ দেখে, অক্ষরের নির্দিষ্ট সংখ্যা দেখে নয়। তাই “৬০ অক্ষর” কোনো নিয়ম নয়, একটি কাজের বাজেট। কিন্তু বাংলায় “অক্ষর” গোনাই প্রথম সমস্যা। JavaScript-এর .length গোনে UTF-16 code unit, আর বাংলায় একটি চোখে-দেখা অক্ষরে কয়েকটি code point থাকে। “স্ট” আসলে স + হসন্ত + ট।
দর্জি মাপ নেন ফিতা দিয়ে, কাপড়ের সুতো গুনে নয়। একটি জামায় কত সুতো, সেটি সত্যি একটি সংখ্যা, কিন্তু জামা গায়ে লাগবে কি না, তা বলে না। .length সুতো গোনে। পাঠক যা দেখেন, তা গোনে grapheme, আর তার জন্য browser আর Node-এ আছে Intl.Segmenter.
নিচের ব্লক বাজারঘরের আসল শিরোনাম আর বিবরণ তিনভাবে মাপে, আর দেখায় .length ধরে কাটলে কী হয়। চালিয়ে দেখুন।
const seg = new Intl.Segmenter('bn', { granularity: 'grapheme' });
const graphemes = (s) => [...seg.segment(s)].map((x) => x.segment);
const bytes = (s) => new TextEncoder().encode(s).length;
function cut(s, max) {
const g = graphemes(s);
return g.length <= max ? s : g.slice(0, max - 1).join('') + '…';
}
const title = 'মিনিকেট চাল ৫ কেজি | বাজারঘর';
const desc = 'ঝরঝরে মিনিকেট চাল, ৫ কেজির প্যাকেট। ঢাকার ভেতরে পরের দিন ডেলিভারি।';
for (const [name, s] of [['title', title], ['description', desc]]) {
console.log(name, '| .length', s.length, '| graphemes', graphemes(s).length, '| UTF-8 bytes', bytes(s));
}
const word = 'স্টক';
console.log('slice(0,2):', JSON.stringify(word.slice(0, 2)), 'graphemes:', JSON.stringify(graphemes(word)));
console.log('cut by .length :', desc.slice(0, 20) + '…');
console.log('cut by grapheme:', cut(desc, 20));
// কাজের বাজেট (নিয়ম নয়): শিরোনাম ~60, বিবরণ ~155 grapheme
const BUDGET = { title: 60, description: 155 };
console.log('title ok:', graphemes(title).length <= BUDGET.title,
'| description ok:', graphemes(desc).length <= BUDGET.description);আউটপুটের প্রথম দুই লাইন: শিরোনামে .length 28, কিন্তু graphemes 20, আর UTF-8-এ 72 byte. বিবরণে ৬৬, ৪৫ আর ১৭৬। অর্থাৎ .length দিয়ে বাজেট মাপলে বাংলা লেখাকে প্রায় দেড় গুণ লম্বা ভাববেন। তৃতীয় লাইনে slice(0,2) দিয়েছে "স্", একটি ঝুলে থাকা হসন্ত, আর grapheme ভাগে ["স্ট","ক"], “স্ট” একটি একক। পরের দুই লাইনে .length ধরে কাটা বিবরণ শেষ হয় “৫…”-এ, grapheme ধরে কাটা বিবরণ শেষ হয় “৫ কেজির…”-এ, কারণ ২০টি grapheme-এ বেশি লেখা ধরে। শেষ লাইন: দুটিই বাজেটের ভেতরে (true).
একটি সতর্কতা: হসন্তসহ যুক্তাক্ষরকে একটি grapheme ধরার নিয়ম Unicode 15.1-এ যোগ হয়েছে। পুরোনো ICU থাকা runtime “স্ট”-কে দুই ভাগে ভাঙতে পারে। তাই নিজের Node বা browser-এ একবার চালিয়ে দেখবেন। Unicode আর যুক্তাক্ষরের ভিত্তি আছে বাংলা ওয়েব পর্বে।
বাস্তবে এই পরীক্ষা কোথায় বসাবেন? generateMetadata-এ বিবরণ grapheme ধরে ছোট করুন, আর CI-তে একটি test রাখুন যা প্রতিটি পণ্যের শিরোনাম বাজেটের বাইরে গেলে সতর্ক করে। Test লেখা আসবে মডিউল ১১-এ।
৫ · sitemap.ts আর robots.ts — ফাইল কোডে বানানো
Sitemap হলো search engine-কে দেওয়া URL-এর তালিকা: “আমার এই পাতাগুলো আছে, এগুলো শেষ কবে বদলেছে।” robots.txt হলো দরজার নোটিশ: “এই পথগুলোতে ঢুকবেন না।” দুটিই সাইটের মূলে থাকে। Next.js-এ app/sitemap.ts আর app/robots.ts লিখলে সে এই দুটি ফাইল বানিয়ে দেয়।
import type { MetadataRoute } from 'next';
import { products, SITE } from '@/lib/products';
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [{ url: SITE, lastModified: '2026-10-30' }];
for (const p of products) {
const languages = {
'bn-BD': `${SITE}/products/${p.slug}`,
en: `${SITE}/en/products/${p.slug}`,
};
// প্রতিটি ভাষার URL নিজের <url> পায়, আর দুটিই একে অপরকে দেখায়
for (const url of Object.values(languages)) {
entries.push({ url, lastModified: p.updatedAt, alternates: { languages } });
}
}
return entries;
}import type { MetadataRoute } from 'next';
import { SITE } from '@/lib/products';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/cart', '/account/', '/api/'] },
sitemap: `${SITE}/sitemap.xml`,
};
}আসল ফল (সত্যিকারের run, sitemap-এর শেষ অংশ ছাঁটা):
~/bazarghar$ curl -s http://127.0.0.1:47658/robots.txt
User-Agent: *
Allow: /
Disallow: /cart
Disallow: /account/
Disallow: /api/
Sitemap: https://bazarghar.example/sitemap.xml
~/bazarghar$ curl -s http://127.0.0.1:47658/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://bazarghar.example</loc>
<lastmod>2026-10-30</lastmod>
</url>
<url>
<loc>https://bazarghar.example/products/miniket-chal-5kg</loc>
<xhtml:link rel="alternate" hreflang="bn-BD" href="https://bazarghar.example/products/miniket-chal-5kg" />
<xhtml:link rel="alternate" hreflang="en" href="https://bazarghar.example/en/products/miniket-chal-5kg" />
<lastmod>2026-10-28</lastmod>
</url>
<url>
<loc>https://bazarghar.example/en/products/miniket-chal-5kg</loc>
…
প্রথম চেষ্টায় আমরা শুধু বাংলা URL-কে <url> দিয়েছিলাম, ইংরেজিটি ছিল শুধু xhtml:link-এ। Sitemap-এ hreflang-এর নিয়মে প্রতিটি ভাষার URL-এর নিজের <url> লাগে, তাই কোড বদলাতে হলো। এমন ভুলও ধরা পড়ে শুধু আসল ফাইল পড়লে। আরও কিছু কথা:
lastmodসত্যি রাখুন। ডেটারupdatedAtথেকে নিন,new Date()থেকে নয়। প্রতিবার build-এ “আজ বদলেছে” বললে সংকেতটির মূল্য থাকে না।changefreqআরpriorityNext.js নিতে পারে, তবে Google-এর নথি বলে Google এ দুটি উপেক্ষা করে।- শুধু canonical, indexযোগ্য URL. Filter, খোঁজা, cart বা 404 পাতা sitemap-এ নয়।
- বড় সাইটে ভাগ। sitemaps.org-এর প্রোটোকল অনুযায়ী এক ফাইলে সর্বোচ্চ ৫০,০০০ URL আর ৫০ MB (চাপ দেওয়ার আগে)। Next.js-এ
generateSitemapsদিয়ে ভাগ করা যায়; Next.js 16-এ সেখানেid-ও একটি Promise. sitemap.tsডিফল্টে cache হয়, যদি না request-এর সময়ের কোনো API ব্যবহার করে। আমাদের table-এ তাই○. পণ্য বদলালে নতুন build বা revalidate লাগবে।
Sitemap হাতে বানাতে গেলে সবচেয়ে সাধারণ ভুল XML-এর escaping. URL-এ & থাকলে XML-এ লিখতে হয় &, আর বাংলা slug percent-encode হয়। নিচের ব্লকে Node নিজেই একটি ছোট sitemap বানায়:
const SITE = 'https://bazarghar.example';
const escapeXml = (s) => s.replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const pages = [
{ path: '/products/miniket-chal-5kg', updated: '2026-10-28' },
{ path: '/products/চিনিগুঁড়া-চাল', updated: '2026-10-31' },
{ path: '/category/rice?page=2&per=48', updated: '2026-11-01' },
];
function sitemap(list) {
const rows = list.map((p) => {
const loc = new URL(p.path, SITE).href; // বাংলা অক্ষর percent-encode হয়
return ` <url><loc>${escapeXml(loc)}</loc><lastmod>${p.updated}</lastmod></url>`;
});
return ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...rows, '</urlset>'].join('\n');
}
const xml = sitemap(pages);
console.log(xml);
console.log('bytes:', Buffer.byteLength(xml), '| raw & left:', /&(?!amp;|lt;|gt;|quot;|apos;)/.test(xml));
// sitemaps.org-এর সীমা: এক ফাইলে সর্বোচ্চ 50,000 URL
for (const n of [3, 50000, 120000]) {
console.log(`${n} URLs -> ${Math.ceil(n / 50000)} sitemap file(s)`);
}দ্বিতীয় <loc>-এ বাংলা slug “চিনিগুঁড়া-চাল” হয়ে গেছে %E0%A6%9A…: প্রতিটি বাংলা code point UTF-8-এ ৩ byte, আর প্রতিটি byte %XX. কাজটি করেছে new URL(). তৃতীয় <loc>-এ ?page=2&per=48: & escape হয়েছে, নইলে XML parser ভাঙত। তারপর bytes: 532 | raw & left: false, কোনো কাঁচা & নেই। শেষ তিন লাইন ভাগের হিসাব: ৩টি বা ৫০,০০০টি URL-এ ১টি ফাইল, ১,২০,০০০টিতে ৩টি (⌈120000 ÷ 50000⌉ = 3).
robots.txt আর noindex একসাথে নয়। Disallow crawler-কে পাতা আনতে দেয় না। তাহলে পাতার ভেতরের noindex-ও সে কখনো পড়বে না। Google-এর নথি সতর্ক করে, এমন URL অন্য সাইটের লিংক থেকে জেনে বিষয়বস্তু ছাড়াই index-এ থাকতে পারে। নিয়ম: index থেকে সরাতে চাইলে noindex দিন আর আনতে দিন; crawl কমাতে চাইলে Disallow. আর robots.txt নিরাপত্তা নয়, ফাইলটি সবাই পড়তে পারেন। গোপন পাতা login দিয়ে আটকান।
৬ · Structured data — Product আর BreadcrumbList-এর JSON-LD
শিরোনাম আর বিবরণ মানুষের ভাষা। কিন্তু “৪৫০” কি দাম, ওজন, নাকি পণ্যের কোড? Structured data যন্ত্রকে স্পষ্ট বলে দেয়: এটি একটি Product, তার Offer-এর দাম 450.00, মুদ্রা BDT, আর এটি স্টকে আছে। শব্দভান্ডার আসে schema.org থেকে, আর লেখার ধরন JSON-LD: পাতার ভেতরে একটি <script type="application/ld+json">. Next.js-এর docs এটিকে layout বা page-এ সাধারণ <script> হিসেবে বসাতে বলে, next/script দিয়ে নয়, কারণ এটি চালানোর কোড নয়, ডেটা।
const url = `${SITE}/products/${p.slug}`;
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'Product',
name: p.nameBn,
description: p.descBn,
sku: p.sku,
image: [`${url}/opengraph-image`],
offers: {
'@type': 'Offer',
url,
price: p.priceBdt.toFixed(2),
priceCurrency: 'BDT',
availability: p.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
itemCondition: 'https://schema.org/NewCondition',
},
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'বাজারঘর', item: SITE },
{ '@type': 'ListItem', position: 2, name: p.categoryBn, item: `${SITE}/category/${p.category}` },
{ '@type': 'ListItem', position: 3, name: p.nameBn },
],
},
];
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
…শেষের .replace(/</g, '\\u003c') লাইনটি বাদ দেবেন না। Next.js-এর docs সতর্ক করে, JSON.stringify ক্ষতিকর লেখা পরিষ্কার করে না। কোনো বিক্রেতা বিবরণে </script> লিখলে সেটি script ট্যাগ বন্ধ করে নিজের HTML চালাতে পারত। <-কে \u003c করলে JSON-এর মান একই থাকে, কিন্তু HTML parser আর ট্যাগ দেখে না। এখানে dangerouslySetInnerHTML কেন বিপজ্জনক, তা পর্ব ৫০-এ দেখেছেন; XSS-এর তত্ত্ব Cybersecurity কোর্সে।
আসল HTML-এ যা গেল (সত্যিকারের run, এক লাইনে ছিল, পড়ার সুবিধায় ভাঙা):
<script type="application/ld+json">[{"@context":"https://schema.org","@type":"Product",
"name":"মিনিকেট চাল ৫ কেজি","description":"ঝরঝরে মিনিকেট চাল, …","sku":"BG-RICE-005",
"image":["https://bazarghar.example/products/miniket-chal-5kg/opengraph-image"],
"offers":{"@type":"Offer","url":"https://bazarghar.example/products/miniket-chal-5kg",
"price":"450.00","priceCurrency":"BDT","availability":"https://schema.org/InStock",
"itemCondition":"https://schema.org/NewCondition"}},
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{"@type":"ListItem","position":1,"name":"বাজারঘর","item":"https://bazarghar.example"},
{"@type":"ListItem","position":2,"name":"চাল","item":"https://bazarghar.example/category/rice"},
{"@type":"ListItem","position":3,"name":"মিনিকেট চাল ৫ কেজি"}]}]</script>
এটি কি schema.org-এর সংজ্ঞার সাথে মেলে? অনুমান না করে আমরা schema.org-এর নিজের প্রকাশিত vocabulary ফাইল (schemaorg-current-https.jsonld, ২ নভেম্বর ২০২৬-এ নামানো) পড়ে একটি ছোট Python script দিয়ে প্রতিটি property মিলিয়েছি: property-টি আছে কি না, আর তার domainIncludes-এ এই type বা তার পূর্বপুরুষ আছে কি না। সত্যিকারের run:
$ python check_schema.py prod.html
Product.name: ok
Product.description: ok
Product.sku: ok
Product.image: ok
Product.offers: ok
Offer.url: ok
Offer.price: ok
Offer.priceCurrency: ok
Offer.availability: ok
value InStock: enum member of ItemAvailability
Offer.itemCondition: ok
value NewCondition: enum member of OfferItemCondition
BreadcrumbList.itemListElement: ok
ListItem.position: ok
ListItem.name: ok
ListItem.item: ok
…
দুটি আলাদা প্রশ্ন গুলিয়ে ফেলবেন না। (১) schema.org-এ বৈধ কি? এটি শব্দভান্ডারের প্রশ্ন, উপরের script বা Schema Markup Validator দেখায়। (২) কোনো search engine এটি দিয়ে বিশেষ ফলাফল দেখাবে কি? এটি সেই engine-এর নিজের নিয়ম। Google-এর নথিতে যে পাতা থেকে সরাসরি কেনা যায় তার জন্য “merchant listing”-এর নিয়ম, যেখানে লেখার সময়ে Product-এ name, image, offers বাধ্যতামূলক, আর Offer-এ শূন্যের বেশি price আর priceCurrency. মুদ্রা ISO 4217-এর তিন অক্ষরের কোড, বাংলাদেশি টাকার জন্য BDT. availability-এ schema.org-এর একটি মান, যেমন https://schema.org/InStock. Shipping আর return policy-র ঘর ঐচ্ছিক, কিন্তু কিছু অভিজ্ঞতার জন্য লাগে। আর সবচেয়ে জরুরি কথা, Google নিজেই লেখে, structured data থাকলেও তার ফিচার ফলাফলে দেখানোর নিশ্চয়তা নেই। Rich result দেখানো হবে কি না, তা search engine ঠিক করে।
নিচের ব্লক একটি পণ্য থেকে JSON-LD বানায়, Google-এর merchant listing-এর বাধ্যতামূলক ঘর যাচাই করে, আর < escape করে। দ্বিতীয় পণ্যে দুটি ইচ্ছাকৃত ভুল আছে।
const SITE = 'https://bazarghar.example';
function productJsonLd(p) {
return {
'@context': 'https://schema.org', '@type': 'Product',
name: p.name, description: p.desc, image: p.image ? [p.image] : undefined,
offers: {
'@type': 'Offer', url: `${SITE}/products/${p.slug}`,
price: p.price.toFixed(2), priceCurrency: 'BDT',
availability: `https://schema.org/${p.inStock ? 'InStock' : 'OutOfStock'}`,
},
};
}
// লেখার সময়ের Google merchant listing docs-এ যেগুলো বাধ্যতামূলক
function missing(ld) {
const checks = {
name: !!ld.name, image: !!ld.image,
'offers.price > 0': Number(ld.offers?.price) > 0,
'offers.priceCurrency': /^[A-Z]{3}$/.test(ld.offers?.priceCurrency ?? ''),
};
return Object.keys(checks).filter((k) => !checks[k]);
}
const items = [
{ slug: 'miniket-chal-5kg', name: 'মিনিকেট চাল ৫ কেজি', desc: 'ঝরঝরে চাল',
price: 450, inStock: true, image: `${SITE}/og/miniket-chal-5kg.png` },
{ slug: 'moshur-dal-1kg', name: 'মসুর ডাল ১ কেজি', desc: 'দেশি ডাল </script><b>অফার</b>',
price: 0, inStock: false },
];
for (const p of items) {
const ld = productJsonLd(p);
const safe = JSON.stringify(ld).replace(/</g, '\\u003c');
console.log(p.slug, '-> missing:', missing(ld).join(', ') || 'none');
console.log(' ', ld.offers.availability, '| price', ld.offers.price, '| "</script" left:', safe.includes('</script'));
}প্রথম পণ্য missing: none: নাম, ছবি, শূন্যের বেশি দাম 450.00 আর BDT, সব আছে। দ্বিতীয়টি missing: image, offers.price > 0: ছবি নেই, আর দাম 0.00. Database-এ দাম না বসানো পণ্য এভাবেই “ফ্রি” হয়ে যায়; এমন পণ্যের JSON-LD না পাঠানোই ভালো। দুটিতেই "</script" left: false: বিবরণে </script> থাকলেও escape করা লেখায় তা আর নেই। আর availability-তে পূর্ণ URL, InStock বা OutOfStock. স্টক শেষ পণ্য লুকাবেন না, সত্যিটা লিখুন।
দুটি ব্যবহারিক নিয়ম। এক, JSON-LD-তে যা লিখবেন, তা পাতায় দৃশ্যমান হতে হবে। পাতায় ৪৫০ টাকা আর JSON-LD-তে ৩৯৯ টাকা লিখলে Google-এর structured data নীতি ভাঙে, আর ক্রেতাও ঠকেন। দুটিই একই p.priceBdt থেকে আসছে, তাই আমাদের কোডে অমিল সম্ভব নয়। দুই, BreadcrumbList-এর শেষ ধাপে item না দিলেও চলে, কারণ সেটি বর্তমান পাতা। Rich Results Test-এর মতো টুলে নিজের পাতা পরীক্ষা করুন, কিন্তু ফলাফলকে প্রতিশ্রুতি ভাববেন না।
৭ · Open Graph, Twitter card আর বাংলা OG ছবি
কেউ পণ্যের লিংক Facebook, WhatsApp বা Messenger-এ দিলে কার্ড তৈরি হয় Open Graph-এর og:title, og:description আর og:image দেখে। X (আগের Twitter) পড়ে Twitter card-এর twitter:* ট্যাগ, না পেলে সাধারণত og ট্যাগে ফিরে যায়। এই bot-গুলো সাধারণত JavaScript চালায় না বলে ব্যাপকভাবে জানা, তবে প্রতিটি প্ল্যাটফর্মের নিয়ম আলাদা আর বদলায়।
প্রতিটি পণ্যের জন্য আলাদা ছবি হাতে বানানো অসম্ভব। Next.js-এ opengraph-image.tsx নামে একটি ফাইল route segment-এ রাখলে সে ছবি বানায় আর head-এ og:image, তার মাপ, type আর alt বসিয়ে দেয়। ছবি আঁকে next/og-এর ImageResponse, যা ভেতরে Satori দিয়ে JSX আর CSS থেকে SVG বানায়, তারপর Resvg দিয়ে PNG. Docs-এর সীমা: শুধু flexbox আর CSS-এর একটি অংশ (grid চলে না), ফন্ট শুধু ttf, otf বা woff, আর JSX, CSS, ফন্ট, ছবি মিলিয়ে bundle ৫০০ KB-এর মধ্যে। Next.js 14-এর আগে এটি next/server থেকে import হতো; পুরোনো tutorial-এ তাই দেখবেন।
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getProduct } from '@/lib/products';
export const alt = 'বাজারঘরের পণ্য';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
// ফন্ট request-এর ওপর নির্ভর করে না, তাই module-এ একবার পড়া
const bengali = await readFile(join(process.cwd(), 'assets/NotoSansBengali-Bold.ttf'));
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const p = await getProduct(slug);
const name = p?.nameBn ?? 'বাজারঘর';
const price = p ? `৳${p.priceBdt}` : '';
return new ImageResponse(
(
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: 80, background: '#0d1f3c', color: 'white',
fontFamily: 'Noto Sans Bengali' }}>
<div style={{ fontSize: 40, color: '#facc15' }}>বাজারঘর</div>
<div style={{ fontSize: 84, marginTop: 24 }}>{name}</div>
<div style={{ fontSize: 64, marginTop: 24 }}>{price}</div>
</div>
),
{ ...size, fonts: [{ name: 'Noto Sans Bengali', data: bengali, weight: 700, style: 'normal' }] },
);
}বাংলা ফন্ট কেন আলাদা করে দিতে হলো? next/og-এর সাথে আসে শুধু একটি Latin ফন্ট (আমাদের node_modules-এ Geist-Regular.ttf). আমরা Noto Sans Bengali Bold-এর একটি static TTF (১,৪৪,৮২৮ byte) assets/-এ রেখেছি; ৫০০ KB সীমার ভেতরে। Variable font বা woff2 না নেওয়াই ভালো, কারণ docs যে format-এর কথা বলে, তার মধ্যে সেগুলো নেই। আর একটি জিনিস আমরা Next.js-এর নিজের কোড পড়ে পেয়েছি: ফন্ট না দিলে @vercel/og (Next-এর ভেতরে 1.0.1) বাংলা লেখা দেখে request-এর সময় fonts.googleapis.com থেকে Noto Sans Bengali আনার চেষ্টা করে। অর্থাৎ ফন্ট ছাড়া ছবি বানাতে আপনার server-কে বাইরের নেটওয়ার্কে যেতে হয়। ইন্টারনেট বন্ধ বা ধীর হলে ছবি ভাঙবে। নিজের ফন্ট নিজের প্রকল্পে রাখুন।
এবার একটি ছবির request ধাপে ধাপে, ল্যাবের আসল মান দিয়ে:
- Preview bot
GETপণ্যের পাতা - og:image
?b91b…ছবির URL - ImageResponse
SatoriJSX → PNG - PNG
200কার্ডের ছবি
GET /products/miniket-chal-5kg User-Agent: facebookexternalhit/1.1 → 200, metadata <head>-এ, JavaScript চালানো লাগে না
og:image …/products/miniket-chal-5kg/opengraph-image?b91b43274e5c72e8 og:image:type image/png og:image:width 1200 · height 630 og:image:alt বাজারঘরের পণ্য
await params → { slug: 'miniket-chal-5kg' }
ফন্ট: আগেই memory-তে, 144828 byte
JSX → Satori (layout, SVG) → Resvg (PNG)
HTTP/1.1 200 OK content-type: image/png cache-control: public, max-age=0, must-revalidate size_download: 36014 byte · প্রথম 0.39 s, পরে 0.016 s
লেখার কথা: মিনিকেট চাল ৫ কেজি
ছবিতে এল: মনিকিটে চাল ৫ কজেি
দাম: ৳450 ঠিক এসেছে
- Bot পাতা আনে।
facebookexternalhit-এর মতো bot JavaScript চালায় না, তাই Next.js metadata<head>-এই দেয়। Bot শুধু HTML পড়ে। - ছবির URL পায়।
og:image-এর শেষে একটি hash,?b91b43274e5c72e8. ছবির কোড বদলালে hash বদলায়, ফলে পুরোনো ছবি cache থেকে আসার ঝুঁকি কমে। মাপ আর alt এসেছেsizeআরaltexport থেকে। - Route ছবি বানায়।
paramsএখানেও Promise (Next.js 16-এর বদল)। ফন্ট module লোডের সময় একবার পড়া হয়েছে, প্রতি request-এ নয়। Satori layout করে SVG বানায়, Resvg সেটিকে PNG করে। - উত্তর আসে। 200,
image/png, ৩৬,০১৪ byte. Build table-এ route-টিƒ, তাই প্রতি request-এ তৈরি; প্রথমটিতে ০.৩৯ সেকেন্ড, পরেরটিতে ০.০১৬ সেকেন্ড। এটি একটি লোকাল run-এর মাপ, সাধারণ ফল নয়। আসল সাইটে CDN-এ cache রাখার কথা ভাবুন। - চোখে দেখে যাচাই। Status 200, তবু ছবি ভুল। “মিনিকেট” হয়ে গেছে “মনিকিটে”, “কেজি” হয়ে গেছে “কজেি”। হ্রস্ব-ই আর এ-কার ব্যঞ্জনের আগে বসেনি। কোনো test এটি ধরেনি; ধরেছে PNG খুলে দেখা।
বাংলা কেন ভাঙল, আর কী করবেন
বাংলায় লেখার ক্রম আর আঁকার ক্রম এক নয়। “মি” লেখা হয় ম তারপর ি, কিন্তু আঁকতে হয় ি আগে। যুক্তাক্ষর “ষ্ট” লেখা হয় ষ + হসন্ত + ট, আঁকতে হয় একটি জোড়া আকার। এই কাজটির নাম text shaping: ফন্টের OpenType নিয়ম পড়ে অক্ষরের ক্রম আর আকার বদলানো। Browser-এ এটি করে HarfBuzz-এর মতো shaping ইঞ্জিন। আমাদের আরেকটি পরীক্ষার ছবিতে (একই ফন্ট) “মিষ্টি দই”, “প্রাণ”, “ক্ষীর” আর “স্টক”-এর একটি যুক্তাক্ষরও জোড়া লাগেনি: প্রতিটিতে হসন্ত আলাদা দৃশ্যমান, আর হ্রস্ব-ই ভুল জায়গায়। ফন্ট ছাড়া (Google Fonts থেকে আনা ফন্টে) একই ভুল। অর্থাৎ সমস্যা ফন্টে নয়, আমাদের run-এ ব্যবহৃত Satori-র shaping-এ। ভবিষ্যতের version-এ এটি বদলাতে পারে, তাই নিজের version-এ নিজে পরীক্ষা করবেন।
| পথ | আমাদের run-এ ফল | খরচ |
|---|---|---|
ImageResponse + বাংলা TTF | অক্ষর আছে, কিন্তু কার-চিহ্ন আর যুক্তাক্ষর ভুল | সবচেয়ে সহজ, তবে বাংলা লেখায় এখন অচল |
ImageResponse, ফন্ট ছাড়া | একই ভুল, আর request-এর সময় বাইরের নেটওয়ার্ক | এড়িয়ে চলুন |
| Build-এর সময় headless browser-এ screenshot | Edge 156-এ “মিনিকেট চাল ৫ কেজি” একদম ঠিক, ২৮,২৫৬ byte | Build-এ browser লাগে; ছবি public/og/-এ, openGraph.images-এ পথ |
| ছবিতে শুধু লোগো, দাম আর পণ্যের ছবি; নাম og:title-এ | ভাঙার মতো বাংলা লেখাই নেই | কার্ডের শিরোনাম তো লেখা হিসেবে আসেই |
তৃতীয় পথের জন্য আমরা Playwright দিয়ে Edge চালিয়ে একটি HTML টেমপ্লেটের screenshot নিয়েছি (সত্যিকারের run): wrote public/og/miniket-chal-5kg.png 28256 bytes, আর ছবিতে নাম ঠিক এসেছে। একটি ছোট খুঁত: আমাদের বাংলা ফন্টে Latin অঙ্ক নেই, তাই “450” browser-এর অন্য ফন্টে এসেছে। টেমপ্লেটে একটি Latin ফন্টও দিন। কোন পথ নেবেন, তা আপনার build ব্যবস্থার ওপর নির্ভর করে; সমাধান যা-ই হোক, প্রতিটি নতুন টেমপ্লেটের অন্তত একটি ছবি নিজের চোখে দেখবেন।
৮ · একই পাতা, অনেক URL — filter, খোঁজা, 404 আর soft 404
পর্ব ৩৪-এ বাজারঘরের তালিকায় filter আর sort বসিয়েছিলেন: ?brand=teer&price=0-500&sort=price. API-র জন্য এটি দারুণ। কিন্তু প্রতিটি filter-এর মিশ্রণ একটি নতুন URL, আর প্রতিটি URL crawler-এর কাছে একটি নতুন পাতা। সাথে যোগ হয় Facebook বিজ্ঞাপনের utm_source, fbclid-এর মতো tracking parameter, আর একই filter ভিন্ন ক্রমে। ফল: একই বিষয়বস্তুর শত শত URL. একে বলে duplicate content, আর filter-এর এই জালকে faceted navigation.
ডাকঘরে একই বাড়ির চিঠিতে কেউ লেখেন “বাড়ি ১২, রোড ৫”, কেউ “রোড ৫, বাড়ি ১২”, কেউ আবার পাশে “জরুরি” লিখে দেন। বাছাইকারী সব চিঠি একই খোপে ফেলেন, কারণ তিনি ঠিকানাকে একটি নির্দিষ্ট রূপে সাজিয়ে পড়েন। Canonical URL হলো সেই সাজানো রূপ, আর normaliser হলো বাছাইকারী।
Google-এর faceted navigation-এর নথির মূল পরামর্শ, সংক্ষেপে: filter-এর URL index-এ না চাইলে crawl-ই কমান (robots.txt দিয়ে, অথবা filter-কে URL fragment-এ রেখে); একান্ত crawl করতে দিলে parameter-এর ক্রম সবসময় একই রাখুন আর একই filter দুবার আসতে দেবেন না; আর ফলহীন মিশ্রণে আসল 404 দিন। Canonical দিয়ে কাজ হয়, তবে নথি অনুযায়ী এটি ধীরে ধীরে crawl কমায় আর robots.txt-এর চেয়ে কম কার্যকর। বাজারঘরের নীতি এই পর্বে সরল: filter বা sort থাকলে noindex, follow আর canonical মূল তালিকায়; tracking parameter সবসময় বাদ; page থাকলে নিজের canonical. বড় সাইটে আপনি হয়তো robots.txt দিয়ে crawl-ও আটকাবেন, তবে মনে রাখবেন, তখন noindex আর পড়া হবে না।
নিচের ব্লক এই normaliser আর সিদ্ধান্তের যুক্তি, কোনো framework ছাড়া। পাঁচটি URL দিয়ে দেখুন:
const TRACKING = /^(utm_[a-z]+|fbclid|gclid)$/;
const FACETS = ['brand', 'price', 'instock', 'sort'];
function normalise(raw) {
const u = new URL(raw); // host ছোট হাতের হয়ে যায়
u.hash = '';
const keep = [...u.searchParams].filter(([k, v]) => v !== '' && !TRACKING.test(k));
keep.sort(([a, x], [b, y]) => (a + '=' + x).localeCompare(b + '=' + y));
u.search = new URLSearchParams(keep).toString();
if (u.searchParams.get('page') === '1') u.searchParams.delete('page');
return u;
}
function decide(u) {
const used = FACETS.filter((f) => u.searchParams.has(f));
if (used.length === 0) return { robots: 'index, follow', canonical: u.href };
return { robots: 'noindex, follow', canonical: u.origin + u.pathname };
}
const urls = [
'https://Bazarghar.example/category/rice?utm_source=fb&fbclid=x1',
'https://bazarghar.example/category/rice?page=1',
'https://bazarghar.example/category/rice?page=2&utm_campaign=eid',
'https://bazarghar.example/category/rice?brand=teer&price=0-500',
'https://bazarghar.example/category/rice?price=0-500&brand=teer#top',
];
const seen = new Set();
for (const raw of urls) {
const u = normalise(raw);
const d = decide(u);
console.log(u.pathname + u.search + (seen.has(u.href) ? ' (duplicate!)' : ''));
seen.add(u.href);
console.log(' ' + d.robots + ' | canonical ' + d.canonical.replace('https://bazarghar.example', ''));
}
console.log('5 URLs ->', seen.size, 'distinct');প্রথম URL-এ বড় হাতের Bazarghar, utm_source আর fbclid; সব সরে হলো /category/rice, index, follow. দ্বিতীয় ?page=1 মূল তালিকারই আরেক নাম, তাই (duplicate!). তৃতীয় ?page=2 আলাদা পাতা, নিজের canonical. চতুর্থ আর পঞ্চম একই দুই filter, ভিন্ন ক্রমে, পঞ্চমে আবার #top; সাজানোর পর দুটিই ?brand=teer&price=0-500, তাই পঞ্চমটি (duplicate!), আর দুটিই noindex, follow | canonical /category/rice. শেষ লাইন: 5 URLs -> 3 distinct. Filter-এর পাতায় follow রাখা হয়েছে, যাতে সেখানকার পণ্যের লিংক ধরে crawler পণ্যের পাতায় যেতে পারে।
একটি URL কীভাবে সিদ্ধান্তে পৌঁছায়, ধাপে ধাপে:
1const u = new URL(raw);2u.hash = ''; drop utm_*, fbclid, gclid3keep.sort(); if page=1 → delete4used = FACETS.filter(f => has(f))5used.length ? noindex : index6<meta name="robots"> · canonical
| URL | |
| used | |
| robots |
- কাঁচা URL. একটি বিজ্ঞাপন থেকে আসা লিংক:
/category/rice?utm_source=fb&price=0-500&brand=teer#top. এর তিনটি অংশ বিষয়বস্তু বদলায় না। - Fragment আর tracking বাদ।
#topserver-এ যায়ই না, আরutm_sourceশুধু বিজ্ঞাপনের হিসাব। থাকল?price=0-500&brand=teer. - ক্রম সাজানো। বর্ণক্রমে সাজালে
?brand=teer&price=0-500. এখন উল্টো ক্রমের লিংকও ঠিক এই একই URL-এ মিলবে। - Filter গোনা।
brandআরprice, দুটি filter আছে। অর্থাৎ এটি মূল তালিকার একটি সরু অংশ। - সিদ্ধান্ত। Filter থাকায়
noindex, follow: পাতাটি index-এ নয়, কিন্তু তার লিংক অনুসরণ করা চলবে। - HTML-এ যায়। Next.js-এর
generateMetadataঠিক এই দুটি ট্যাগ বসায়; ল্যাবেরcurl-এ/category/rice?brand=x&sort=price-এ এই দুটি লাইনই এসেছে।
Next.js-এ একই যুক্তি generateMetadata-এ, যেখানে searchParams-ও Promise:
const FACETS = ['brand', 'price', 'sort', 'instock'];
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
const { cat } = await params;
const sp = await searchParams;
const used = FACETS.filter((f) => sp[f] !== undefined);
const base = `/category/${cat}`;
if (used.length === 0) {
return { title: `${cat} — সব পণ্য`, alternates: { canonical: base } };
}
return {
title: `${cat} — ফিল্টার করা তালিকা`,
alternates: { canonical: base },
robots: { index: false, follow: true },
};
}খোঁজার ফলের পাতা
/search?q=চাল অসীম সংখ্যক URL তৈরি করতে পারে। Google-এর পুরোনো Webmaster নির্দেশিকায় সাইটের ভেতরের খোঁজার ফলের পাতা crawl না করানোর পরামর্শ ছিল, আর এগুলোকে index-এর বাইরে রাখা আজও প্রচলিত অভ্যাস, কারণ এমন পাতা ক্রেতাকে আরেকটি খোঁজার পাতায় নিয়ে যায় মাত্র। তাই /search পাতায় static metadata-তেই robots: { index: false, follow: true }. আসল run-এ head-এ এল <meta name="robots" content="noindex, follow"/> আর শিরোনাম “খোঁজার ফল | বাজারঘর”।
404 আর soft 404
যে পণ্য নেই, তার পাতা কী ফেরত দেবে? দুটি route পাশাপাশি চালিয়ে দেখুন (সত্যিকারের run):
~/bazarghar$ for u in /products/miniket-chal-5kg /products/no-such-item /soft404/no-such-item; do
curl -s -o /dev/null -w "%{http_code} %{size_download} $u\n" "http://127.0.0.1:47658$u"; done
200 13046 /products/miniket-chal-5kg
404 8493 /products/no-such-item
200 6866 /soft404/no-such-item
প্রথম route notFound() ডাকে: status 404, আর Next.js head-এ নিজেই <meta name="robots" content="noindex"/> বসিয়েছে। দ্বিতীয় route ইচ্ছা করে ভুলভাবে লেখা: পর্দায় “দুঃখিত, পণ্যটি পাওয়া যায়নি”, কিন্তু status 200 আর কোনো noindex নেই। এটিই soft 404. Crawler একে আসল পাতা ভাবতে পারে, আর শত শত “পাওয়া যায়নি” পাতা একই রকম দেখায়। Google এমন পাতা চিনে নেওয়ার চেষ্টা করে আর Search Console-এ “soft 404” হিসেবে দেখায়, কিন্তু দায়িত্ব আপনার। পর্ব ২০-এ SPA-তে এই সমস্যা দেখেছেন, যেখানে server সবসময় একই index.html আর 200 দেয়।
আরেকটি জিনিস লক্ষ করুন: 404 পাতার head-এ og:image-ও এসেছে, /products/no-such-item/opengraph-image, কারণ ছবির ফাইলটি segment-এ আছে। আমাদের ছবির কোড পণ্য না পেলে “বাজারঘর” লেখে, তাই ক্ষতি নেই। কিন্তু ছবির কোড যদি ধরে নিত পণ্য আছেই, তাহলে 404 পাতার ছবি 500 দিত। Edge case-গুলো নিজের চোখে দেখুন।
সাবধান: Cache Components চালু থাকলে notFound()-ও 200 দিতে পারে
উপরের run ছিল Cache Components বন্ধ রেখে। একই কোড আমরা আরেকটি কপিতে next.config.ts-এ cacheComponents: true আর partialPrefetching: true দিয়ে চালিয়েছি, যেমন create-next-app 16.4.4 লেখে। Build-এ প্রথমে আসল error এল: /category/[cat]-এ params <Suspense>-এর বাইরে পড়া হয়েছে। Dynamic অংশগুলো <Suspense>-এ মোড়ার পর route table-এ নতুন চিহ্ন এল, ◐ /products/[slug], অর্থাৎ partial prerender: static খোলস আগে, বাকিটা পরে stream. তারপর অচেনা slug-এ (সত্যিকারের run):
~/bazarghar-cc$ curl -s -D - -o first.html -A "Mozilla/5.0 Chrome/140" http://127.0.0.1:47659/products/abc-first
HTTP/1.1 200 OK
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
~/bazarghar-cc$ curl -s -D - -o second.html -A "Mozilla/5.0 Chrome/140" http://127.0.0.1:47659/products/abc-first
HTTP/1.1 404 Not Found
Cache-Control: s-maxage=31536000
নতুন slug, প্রথম request: facebookexternalhit/1.1 → 404 Googlebot/2.1 → 200
প্রথম request-এ status 200, যদিও body-তে “দুঃখিত, পাতাটি পাওয়া যায়নি” আর head-এ <meta name="robots" content="noindex"/> আছে। একই slug-এর দ্বিতীয় request-এ 404, কারণ ততক্ষণে ফলটি জানা। Link preview-এর bot (HTML-limited) প্রথমবারেই 404 পেল, কারণ তার জন্য Next.js পুরো পাতা শেষ করে তবে পাঠায়; Googlebot প্রথমবার পেল 200. Next.js-এর notFound docs ঠিক এটিই বলে: response stream শুরু হওয়ার পর notFound() চললে status 200 থেকে যায়, কারণ header আগেই চলে গেছে, তবে Next.js noindex বসায়; stream শুরুর আগে চললে 404. HTTP-তে status থাকে উত্তরের একদম শুরুতে, তাই পরে মত বদলানোর উপায় নেই।
এটি কি soft 404? আংশিক। noindex থাকায় পাতাটি index-এ যাওয়ার কথা নয়, আর Google-এর নথিও SPA-র জন্য এই পথটিকে (noindex) একটি গ্রহণযোগ্য বিকল্প বলে। তবু 404-ই সবচেয়ে পরিষ্কার সংকেত, আর monitoring বা অন্য bot শুধু status দেখে। আসল 404 চাইলে পণ্যের অস্তিত্ব যাচাই করুন stream শুরুর আগে: <Suspense>-এর বাইরে, page-এর ওপরের অংশে (যা ওই route-কে কম static করে), জানা slug-এর জন্য generateStaticParams দিয়ে, অথবা docs-এর পরামর্শমতো request-এর সময় Proxy-তে। যে config-ই ব্যবহার করুন, deploy-এর আগে নতুন অচেনা slug দিয়ে, প্রথম request-এ, কয়েক রকম User-Agent দিয়ে status দেখে নিন। Proxy আর route handler আছে পর্ব ৫৭-এ।
| পরিস্থিতি | সঠিক উত্তর | Next.js-এ |
|---|---|---|
| পণ্য কখনো ছিল না, বা মুছে গেছে | 404 (বা স্থায়ীভাবে গেলে 410) | stream শুরুর আগে notFound() |
| পণ্যের slug বদলেছে | 301/308 নতুন URL-এ | permanentRedirect() বা config-এর redirect |
| পণ্য আছে, স্টক নেই | 200, পাতা থাকবে | JSON-LD-তে OutOfStock |
| Filter-এর মিশ্রণে কোনো পণ্য নেই | Google-এর পরামর্শ 404 | ফল শূন্য হলে notFound() |
| খোঁজায় কিছু মেলেনি | 200 + noindex (পাতাটিই noindex) | static robots |
স্টক শেষ হলে পাতা মুছে ফেলবেন না। পণ্য আবার আসবে, আর পুরোনো লিংকগুলো কাজে লাগবে। HTTP status-এর অর্থ নিয়ে পুরো আলোচনা আছে পর্ব ৩৩-এ।
৯ · Core Web Vitals আর বাজারঘরের পণ্যের পাতার তালিকা
গতি কি SEO? Google-এর নথি অনুযায়ী Core Web Vitals তাদের ranking ব্যবস্থায় ব্যবহৃত হয়। একই নথি বলে, ভালো স্কোর শীর্ষে থাকার নিশ্চয়তা দেয় না, আর Google সবচেয়ে প্রাসঙ্গিক বিষয়বস্তু দেখানোর চেষ্টা করে, পাতার অভিজ্ঞতা দুর্বল হলেও। অর্থাৎ এটি অনেক সংকেতের একটি, আর কতটা ভার পায়, তা প্রকাশিত নয়। লেখার সময়ের লক্ষ্য: LCP ২.৫ সেকেন্ডের মধ্যে, INP ২০০ মিলিসেকেন্ডের কম, CLS ০.১-এর কম। এগুলো আসল ব্যবহারকারীর মাপ থেকে বিচার হয়। ধীর ফোনে কীভাবে মাপবেন আর কমাবেন, তা পর্ব ২৩-এ দেখেছেন; ছবি আর cache header-সহ পুরো আলোচনা পর্ব ৮৪-এ (Web performance — Core Web Vitals, image আর cache header).
SEO-র দিক থেকে কয়েকটি সংযোগ আছে। Static (●) পণ্যের পাতা CDN থেকে আসে বলে TTFB কম থাকে, যা LCP-তে সাহায্য করে। পণ্যের ছবিতে width আর height দিলে CLS কমে। আর Next.js-এর streaming metadata ধীর generateMetadata-কে প্রথম HTML আটকাতে দেয় না। কিন্তু ১০০ নম্বর পেতে গিয়ে বিষয়বস্তু বাদ দেবেন না; ক্রেতার প্রশ্নের উত্তর দেওয়াই আসল কাজ।
এবার পুরো পর্ব এক তালিকায়। বাজারঘরের প্রতিটি নতুন পণ্যের পাতা বা টেমপ্লেট পাঠানোর আগে এগুলো মিলিয়ে নিন। ডান কলামে যাচাইয়ের উপায়:
| # | যাচাই | কীভাবে দেখবেন |
|---|---|---|
| ১ | শিরোনাম “পণ্যের নাম | বাজারঘর”, বাজেটের ভেতরে (grapheme) | curl-এ <title>, CI-তে grapheme গোনা |
| ২ | বিবরণ পণ্যের নিজের, ভাষা ঠিক | ইংরেজি পাতায় বাংলা description নেই |
| ৩ | Canonical পূর্ণ URL, query ছাড়া | grep 'rel="canonical"' |
| ৪ | hreflang bn-BD, en, x-default, দুই দিক থেকে | দুই ভাষার পাতাই খুলে দেখা |
| ৫ | JSON-LD: name, image, offers, BDT, শূন্যের বেশি দাম, সত্যি availability | schema.org validator, পাতার দামের সাথে মেলানো |
| ৬ | JSON-LD-তে < escape করা | বিবরণে </script> দিয়ে test |
| ৭ | og:title, og:image (1200×630), twitter:card | curl -A facebookexternalhit/1.1 |
| ৮ | OG ছবিতে বাংলা ঠিক আঁকা | PNG খুলে চোখে দেখা |
| ৯ | না থাকা পণ্যে 404, slug বদলে 308 | curl -w "%{http_code}" |
| ১০ | Filter, sort, খোঁজা: noindex, follow; tracking বাদ | URL normaliser-এর test |
| ১১ | Sitemap-এ শুধু canonical URL, সত্যি lastmod; robots.txt-এ sitemap | curl /sitemap.xml, XML parser দিয়ে পড়া |
| ১২ | নাম, দাম, বিবরণ প্রথম HTML-এ (JavaScript ছাড়া) | JavaScript বন্ধ করে বা curl-এ দেখা |
| ১৩ | LCP, INP, CLS লক্ষ্যের কাছে, আসল ফোনে মাপা | Field data, Lighthouse শুধু সূচনা |
আরও গভীরে: robots.txt-এর নিয়ম ১৯৯৪ সালে Martijn Koster-এর প্রস্তাব থেকে আসে, আর প্রায় তিন দশক পর ২০২২ সালে IETF-এ RFC 9309 হিসেবে মান পায়। Sitemap-এর XML প্রোটোকল sitemaps.org-এ লেখা, যা বড় search engine-গুলো মিলে সমর্থন করে। Canonical-এর rel="canonical" link relation পরে RFC 6596-এ নথিভুক্ত হয়েছে। আর JSON-LD একটি W3C মান; এর মূল ধারণা, সাধারণ JSON-কে @context দিয়ে বিশ্বজনীন শব্দভান্ডারের সাথে যুক্ত করা।
পরীক্ষা ও ইন্টারভিউতে:
- Canonical আর 301-এর পার্থক্য? 301 ব্যবহারকারীকেও সরায়; canonical শুধু search engine-কে আসল URL জানায়, আর এটি সংকেত, আদেশ নয়।
- robots.txt-এ Disallow দিলে কি পাতা index থেকে সরে? না, নিশ্চিতভাবে নয়। Crawl বন্ধ হয়, তাই
noindex-ও পড়া হয় না। - Soft 404 কী? পর্দায় “পাওয়া যায়নি”, কিন্তু HTTP 200. সমাধান আসল 404, Next.js-এ stream শুরুর আগে
notFound(); পরে ডাকলে 200 থাকে, তবে noindex বসে। - Next.js 16-এ
generateMetadata-এরparamsকেমন? একটি Promise;await paramsলিখতে হয়। - Structured data দিলেই কি rich result আসে? না। বৈধতা প্রয়োজনীয় শর্ত, কিন্তু দেখানোর সিদ্ধান্ত search engine-এর।
১০ · AI-এর যুগে SEO-র কোড
SEO-র কোড AI-কে দিয়ে লেখানো লোভনীয়, কারণ এটি একঘেয়ে। কিন্তু এখানে ভুল চোখে পড়ে না: পাতা ঠিকই চলে, শুধু কয়েক সপ্তাহ পরে দেখবেন search-এ আসছে না। AI-এর সাধারণ ভুলগুলো:
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
Pages Router-এর next/head বা next-seo দিয়ে App Router-এর কোড | Next.js-এর নিজের version-এর docs খুলে API মেলান |
params.slug সরাসরি, await ছাড়া | next build-এর type check আর error পড়ুন |
| নেই এমন metadata field বা schema.org property বানানো | TypeScript-এর Metadata type; schema.org-এর সংজ্ঞায় খোঁজা |
JSON-LD-তে JSON.stringify escape ছাড়া | বিবরণে </script> রেখে test |
| সব পাতায় একই canonical (হোমপেজ), বা UTM-সহ canonical | curl দিয়ে কয়েকটি পাতার canonical পাশাপাশি দেখা |
| robots.txt-এ Disallow আর পাতায় noindex একসাথে | প্রতিটি URL-এর জন্য একটি লক্ষ্য লিখে মেলানো |
| “Rich result নিশ্চিত”, “এতে ranking বাড়বে” জাতীয় দাবি | Search engine-এর নিজের নথিতে উৎস চান |
| OG ছবির জন্য অচেনা package বা নেটওয়ার্ক-নির্ভর ফন্ট | npm-এ নাম যাচাই; ফন্ট নিজের প্রকল্পে; PNG চোখে দেখা |
AI-কে কাজ দিন সীমানা টেনে, আর ফলাফল নিজের build-এ যাচাই করুন। Search Console-এর ডেটা, আসল ক্রেতার তথ্য বা কোনো secret prompt-এ দেবেন না।
AI-এর লেখা SEO কোড মেলানোর চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Google যদি JavaScript চালিয়েই পাতা পড়ে, তাহলে SEO-র জন্য server-side rendering নিয়ে এত কথা কেন?
ইতিহাসটা দেখলে বোঝা যায়। ২০০০-এর দশকের শেষে AJAX-নির্ভর সাইট বাড়ছিল, আর crawler তখন মূলত শুধু HTML পড়ত। ২০০৯ সালে Google একটি “AJAX crawling scheme” প্রস্তাব করে: URL-এ #! রাখলে crawler একটি বিশেষ _escaped_fragment_ URL চাইবে, আর server সেখানে আগে থেকে render করা HTML দেবে। অর্থাৎ শুরু থেকেই সমাধান ছিল “server-ই HTML দিক”। ২০১৫ সালে Google এই scheme-কে অপ্রচলিত ঘোষণা করে, কারণ তাদের দাবি অনুযায়ী তারা নিজেরাই পাতা render করতে পারছিল। ২০১৯ সালে তারা জানায়, Googlebot এখন সর্বশেষ Chromium-এর সাথে তাল মিলিয়ে চলে (“evergreen”).
তাহলে সমস্যা কোথায়? প্রথমত, render একটি আলাদা ধাপ, সারিতে অপেক্ষা করে। Google-এর নথি নিজেই প্রথম crawl, তারপর render, তারপর index, এই তিন ধাপ বর্ণনা করে। দ্বিতীয়ত, Google একমাত্র পাঠক নয়। Link preview-এর bot, অন্য search engine, AI-এর crawler, এদের অনেকে JavaScript চালায় না বা সীমিতভাবে চালায়, আর তাদের নীতি সবসময় প্রকাশিত বা স্থির নয়। তৃতীয়ত, JavaScript-এ কিছু জিনিস ঝুঁকিপূর্ণ: Google-এর নথি সতর্ক করে, প্রথম HTML-এ noindex থাকলে Google render না-ও করতে পারে, তাই JavaScript দিয়ে পরে সেটি সরালে কাজ নাও হতে পারে।
এখানে একটি সূক্ষ্ম প্রশ্ন: SSR কি সরাসরি ranking বাড়ায়? এমন কোনো প্রকাশিত নিয়ম আমাদের জানা নেই। SSR যা করে তা হলো ঝুঁকি কমানো আর গতি বাড়ানো, যা ব্যবহারকারীর অভিজ্ঞতায় কাজে লাগে। Next.js এই দুই দিক মেলায়: পণ্যের পাতা build-এ তৈরি, metadata server-এ, আর ক্রেতার জন্য interactivity hydration-এর পর। বাজারঘরের মতো দোকানে পণ্যের পাতা প্রকাশ্য আর খুঁজে পাওয়ার যোগ্য; সেখানে প্রথম HTML-এ সব রাখার খরচ কম, লাভ স্পষ্ট। কিন্তু login-এর পেছনের dashboard-এ এই যুক্তি খাটে না, সেখানে CSR-ও ঠিক আছে।
মূল উপলব্ধি: “Google JavaScript পড়তে পারে” কথাটি সত্য, কিন্তু “সবাই সবসময় সময়মতো পড়বে” কথাটি নয়। যে তথ্য খুঁজে পাওয়া জরুরি, তা প্রথম HTML-এ রাখা সবচেয়ে কম অনুমাননির্ভর পথ।
প্র ০২Canonical “আদেশ” নয়, “সংকেত” কেন? Search engine আমার কথা না মানলে কী হয়?
rel="canonical" আসে ২০০৯ সালে, যখন Google, Yahoo আর Microsoft মিলে duplicate URL-এর সমস্যার একটি সাধারণ সমাধান ঘোষণা করে। পরে ২০১২ সালে এটি RFC 6596 হিসেবে নথিভুক্ত হয়। শুরু থেকেই এটিকে একটি জোরালো ইঙ্গিত হিসেবে দেখা হয়েছে, বাধ্যতামূলক নির্দেশ হিসেবে নয়। কারণ সহজ: ওয়েবে ভুল canonical অনেক। কোনো CMS-এর ভুলে সব পাতার canonical হোমপেজ হলে, search engine তা হুবহু মানলে পুরো সাইট একটি পাতায় নেমে আসত।
তাই Google-এর নথি অনুযায়ী canonical বাছাইয়ে আরও সংকেত দেখা হয়: redirect, sitemap-এ কোন URL আছে, ভেতরের লিংক কোন URL-এ যায়, HTTPS কি না, আর বিষয়বস্তু আসলে কতটা মেলে। এগুলো একমত হলে আপনার পছন্দই সাধারণত টেকে। এগুলো পরস্পরবিরোধী হলে, যেমন canonical বলে /products/a কিন্তু sitemap আর সব লিংক দেয় /products/a?ref=home, তখন search engine নিজে বেছে নিতে পারে, আর Search Console-এ “Google chose different canonical than user” জাতীয় বার্তা দেখা যেতে পারে।
বাজারঘরের জন্য এর অর্থ, canonical একা নয়, পুরো ব্যবস্থা একই কথা বলবে। generateMetadata-এর canonical, sitemap.ts-এর URL, <Link href>-এর পথ, আর redirect, সব একই normaliser থেকে আসা উচিত। আমাদের normaliser ব্লকে tracking parameter বাদ আর ক্রম সাজানো ঠিক এই কাজই করে। আরেকটি সাধারণ ভুল: “duplicate content-এর জন্য শাস্তি হয়”। Google বহুবার বলেছে, সাধারণ duplicate-এ শাস্তি নয়, বরং কোন রূপটি দেখাবে, সেই বাছাই হয়; প্রতারণার উদ্দেশ্যে নকল করা আলাদা বিষয়। আসল ক্ষতি হলো crawl-এর সময় অপ্রয়োজনীয় URL-এ খরচ হওয়া আর সংকেত ভাগ হয়ে যাওয়া।
মূল উপলব্ধি: Canonical হলো আপনার মতামত, আর বাকি সাইট তার সাক্ষী। সাক্ষীরা একমত হলে মতামত টেকে; তাই URL তৈরির নিয়ম একটি জায়গায় রাখুন।
প্র ০৩schema.org কেন তৈরি হলো, আর সঠিক JSON-LD দিলেও rich result কেন না আসতে পারে?
২০১১ সালের আগে প্রতিটি search engine নিজের নিজের structured data-র ধরন চাইত: microformats, RDFa, নানা নিজস্ব ধরন। ওয়েবমাস্টারদের পক্ষে সবগুলো লেখা কঠিন ছিল। ২০১১ সালে Google, Microsoft (Bing) আর Yahoo মিলে schema.org চালু করে, একটি সাধারণ শব্দভান্ডার; সে বছরের শেষে Yandex যোগ দেয়। JSON-LD আলাদা একটি W3C মান, ২০১৪ সালে প্রথম সংস্করণ। এর সুবিধা, structured data HTML-এর ভেতরে ছড়িয়ে না রেখে একটি আলাদা JSON ব্লকে রাখা যায়, যা কোড থেকে বানানো সহজ। আমাদের Next.js কোডে ঠিক তাই হয়েছে: ডেটা থেকে একটি object, তারপর একটি script ট্যাগ।
কিন্তু শব্দভান্ডারের বৈধতা আর search engine-এর ব্যবহার দুটি আলাদা স্তর। schema.org-এ Product-এর শত শত property আছে; Google তার মধ্যে কিছু পড়ে, নিজের শর্ত যোগ করে (যেমন merchant listing-এ শূন্যের বেশি দাম), আর তারপর নিজের বিচারে ঠিক করে দেখাবে কি না। এই বিচার বদলায়ও। ২০২৩ সালে Google জানায়, FAQ rich result মূলত সুপরিচিত সরকারি ও স্বাস্থ্য-বিষয়ক সাইটে সীমিত হবে, আর HowTo rich result সরানো হবে। যাঁরা শুধু rich result-এর আশায় এই markup বসিয়েছিলেন, তাঁদের প্রত্যাশা হঠাৎ বদলে যায়, যদিও markup বৈধই ছিল।
তাহলে কেন লিখবেন? কারণ structured data শুধু Google-এর জন্য নয়। এটি আপনার পাতার তথ্যকে যন্ত্রের পড়ার মতো করে, যা অন্য search engine, তুলনার সেবা বা ভবিষ্যতের টুলও পড়তে পারে। আর সঠিক দাম ও availability ক্রেতার সাথে সততারও প্রশ্ন। তবে দুটি নিয়ম মানুন: যা পাতায় দৃশ্যমান নয়, তা markup-এ দেবেন না; আর ফলাফলের প্রতিশ্রুতি দেবেন না, শুধু বৈধতা আর সত্যতা নিশ্চিত করবেন।
মূল উপলব্ধি: schema.org একটি ভাষা, rich result একটি সুযোগ। ভাষা ঠিক বলা আপনার দায়িত্ব; সুযোগ দেওয়া না দেওয়া search engine-এর সিদ্ধান্ত, আর সেটি সময়ের সাথে বদলায়।
প্র ০৪একই ফন্ট দিয়ে browser বাংলা ঠিক আঁকে, অথচ OG ছবির ইঞ্জিন ভুল আঁকে। ভেতরে আসলে কী ঘটছে?
ফন্ট ফাইল শুধু অক্ষরের ছবির সংগ্রহ নয়। OpenType ফন্টে দুই রকম নিয়মের টেবিল থাকে: GSUB, যা বলে কোন অক্ষরের ক্রম কোন জোড়া আকারে বদলাবে (যেমন ক + হসন্ত + ষ মিলে ক্ষ), আর GPOS, যা বলে কোন চিহ্ন কোথায় বসবে। কিন্তু টেবিল পড়ে ঠিক ক্রমে প্রয়োগ করার কাজটি ফন্টের নয়, shaping ইঞ্জিনের। বাংলার মতো লিপিতে ইঞ্জিনকে আগে লিপির নিজস্ব নিয়ম জানতে হয়: হ্রস্ব-ই, এ-কার, ঐ-কার ব্যঞ্জনের আগে আঁকা হয়, যদিও Unicode-এ পরে লেখা; রেফ (র্ক-এর র) ওপরে যায়; যুক্তাক্ষর কোথায় শুরু কোথায় শেষ, তা হসন্ত দেখে বোঝা যায়।
Browser-গুলো এই কাজে HarfBuzz-এর মতো পরিণত shaping ইঞ্জিন ব্যবহার করে, যা বহু বছর ধরে ভারতীয় লিপির নিয়ম শিখেছে। Microsoft এই লিপিগুলোর জন্য আলাদা নির্দেশিকা প্রকাশ করেছিল, আর পরে Universal Shaping Engine-এর মতো সাধারণ কাঠামো আসে। একটি ছোট SVG-বানানো library-র পক্ষে এই পুরো স্তর বানানো বড় কাজ। আমাদের run-এ ঠিক এটাই দেখা গেল: অক্ষরগুলো সঠিক ফন্ট থেকে এসেছে, কিন্তু Unicode-এর ক্রমেই বসেছে। “মিনিকেট” হয়ে গেছে “মনিকিটে”, কারণ ি আর ে নিজের জায়গায় সরেনি, আর যুক্তাক্ষরে হসন্ত দৃশ্যমান।
এর একটি সমান্তরাল দেখেছেন grapheme গোনায়। Unicode 15.1 (২০২৩) পর্যন্ত বাংলার হসন্তযুক্ত যুক্তাক্ষর grapheme-এর নিয়মে একক ধরা হতো না; নতুন নিয়ম আসায় এখন “স্ট” একটি grapheme. অর্থাৎ বাংলার মতো লিপির সঠিক সমর্থন সফটওয়্যারের প্রতিটি স্তরে আলাদা করে আসে, আর পুরোনো স্তর পিছিয়ে থাকে। Latin লিপিতে কাজ করা কোনো test এই ভুল ধরবে না।
ব্যবহারিক শিক্ষা তিনটি। এক, “ফন্ট দিয়েছি, তাই ঠিক আছে” ধরে নেবেন না; shaping আলাদা প্রশ্ন। দুই, যেখানে আঁকার কাজ পরিণত ইঞ্জিনে হয় (browser), সেখানে বানান, যেমন build-এর সময় screenshot. তিন, প্রতিটি নতুন টুলের version-এ যুক্তাক্ষর, কার-চিহ্ন আর রেফ-সহ একটি ছোট পরীক্ষার লেখা রাখুন, যেমন “মিষ্টি, প্রাণ, ক্ষীর, কর্ম”, আর ছবিটি নিজের চোখে দেখুন।
মূল উপলব্ধি: বাংলা ঠিকভাবে দেখানো তিন স্তরের কাজ: সঠিক byte, সঠিক ফন্ট আর সঠিক shaping. যেকোনো একটি ভাঙলে লেখা ভাঙে, আর তৃতীয়টি সবচেয়ে কম চোখে পড়ে।
প্র ০৫Core Web Vitals-এ সবুজ হলে কি search-এ ওপরে উঠব? এই সংকেতকে কতটা গুরুত্ব দেওয়া উচিত?
Google ২০২০ সালে Core Web Vitals ঘোষণা করে, আর ২০২১ সালে “page experience” আপডেটের মাধ্যমে এগুলো ranking-এর সংকেত হিসেবে যুক্ত হয়। শুরুতে তিনটি মাপ ছিল LCP, FID আর CLS. ২০২৪ সালের মার্চে FID-এর জায়গায় আসে INP, কারণ FID শুধু প্রথম interaction-এর অপেক্ষা মাপত, আর INP পুরো ভিজিটের প্রতিক্রিয়া দেখে। লক্ষ্যগুলো (LCP ২.৫ সেকেন্ড, INP ২০০ মিলিসেকেন্ড, CLS ০.১) আসল ব্যবহারকারীদের মাপের ৭৫তম শতাংশে বিচার করার কথা বলা হয়।
কিন্তু Google-এর নথির ভাষা সতর্ক। তারা বলে, Core Web Vitals ranking ব্যবস্থায় ব্যবহৃত হয়, কিন্তু ভালো স্কোর শীর্ষে থাকার নিশ্চয়তা দেয় না, আর প্রাসঙ্গিকতাই আগে। কোনো সংকেত কতটা ভার পায়, তা প্রকাশিত নয়। তাই “LCP ১ সেকেন্ড কমালে ১০ ধাপ ওপরে” জাতীয় দাবি উৎসহীন। বাইরের কিছু গবেষণা সম্পর্ক দেখানোর চেষ্টা করেছে, কিন্তু সম্পর্ক কারণ প্রমাণ করে না: দ্রুত সাইটগুলো প্রায়ই বড় আর সুপরিচিত দলের, যাদের বিষয়বস্তুও ভালো।
তবু গতিকে উপেক্ষা করা ভুল, কারণ এর আসল সুফল ক্রেতার কাছে। ধীর ৩জি বা ভিড়ের সময়ের মোবাইল নেটওয়ার্কে একটি ভারী পণ্যের পাতা দীর্ঘ সময় ফাঁকা থাকলে অনেকে ফিরে যান, যদিও ঠিক কতজন, তা আপনার নিজের analytics-এ মাপতে হবে। আর Lighthouse-এর একক run নয়, field data দেখুন, কারণ Google-ও আসল ব্যবহারকারীর মাপ থেকে বিচার করে বলে জানায়।
অগ্রাধিকারের একটি ক্রম তাই এমন হতে পারে: প্রথমে crawl আর index-যোগ্যতা (status code, canonical, প্রথম HTML), তারপর বিষয়বস্তুর মান (স্পষ্ট নাম, সঠিক দাম, কাজের বিবরণ), তারপর গতি। প্রথম দুটি ভাঙা থাকলে গতি কোনো কাজে আসবে না। আর গতির কাজে মাপুন আগে, অনুমান পরে, যেমন পর্ব ২৩-এ দেখেছেন।
মূল উপলব্ধি: Core Web Vitals একটি প্রকাশিত, কিন্তু অনেকের মধ্যে একটি সংকেত। গতি বাড়ান ক্রেতার জন্য; ranking-এর লাভকে বোনাস ধরুন, প্রতিশ্রুতি নয়।
অনুশীলন
হিসাব, কমান্ড, সমালোচনা আর আউটপুট অনুমান, চারটিই আছে।
-
Filter আর sitemap-এর হিসাব: বাজারঘরে ৩০টি বিভাগ। প্রতিটি বিভাগে filter: brand (৪টি মানের একটি, বা নেই), price (৫টি দামের সীমার একটি, বা নেই), instock (হ্যাঁ, বা নেই), sort (৩টি ধরনের একটি, বা নেই)। (ক) একটি বিভাগে কতটি আলাদা filter-মিশ্রণের URL হতে পারে, filter ছাড়া মূল তালিকাসহ? (খ) ৩০টি বিভাগে? (গ) Parameter-এর ক্রম যদি ব্যবহারকারীর ক্লিকের ক্রমে আসে, তাহলে একটি বিভাগে কতটি ভিন্ন URL? (ঘ) ১,২০,০০০টি পণ্য, প্রতিটির বাংলা আর ইংরেজি পাতা: sitemap-এ কতটি URL, আর ৫০,০০০-এর সীমায় কতটি ফাইল?
(ক) প্রতিটি filter-এ “নেই” একটি বিকল্প, তাই 5 × 6 × 2 × 4 = ২৪০টি। (খ) 240 × 30 = ৭,২০০টি, যার মধ্যে index-যোগ্য শুধু ৩০টি মূল তালিকা (pagination বাদে)। (গ) যে মিশ্রণে k-টি filter আছে, তার k! রকম ক্রম। হিসাব: ০টি filter-এ ১; ১টিতে 4 + 5 + 1 + 3 = ১৩; ২টিতে (20 + 4 + 12 + 5 + 15 + 3) × 2 = ১১৮; ৩টিতে (20 + 60 + 15 + 12) × 6 = ৬৪২; ৪টিতে 60 × 24 = ১,৪৪০. মোট ২,২১৪টি URL, ৩০টি বিভাগে ৬৬,৪২০টি। ক্রম সাজানোর normaliser এক ধাপে সংখ্যাটি ২,২১৪ থেকে ২৪০-এ নামায়। (ঘ) 1,20,000 × 2 = ২,৪০,০০০টি URL; ⌈240000 ÷ 50000⌉ = ⌈৪.৮⌉ = ৫টি ফাইল, সাথে একটি sitemap index.
-
Terminal থেকে যাচাই (কমান্ড): একটি চালু Next.js সাইটের পণ্যের পাতার জন্য এমন
curlকমান্ড লিখুন, যা (ক) শুধু HTTP status দেখায়, (খ) canonical-এর লাইন বের করে, (গ) link preview-এর bot সেজে og:image পড়ে, (ঘ) robots.txt-এ sitemap-এর লাইন আছে কি না দেখে।(ক)
curl -s -o /dev/null -w "%{http_code}\n" URL. (খ)curl -s URL | grep -o '<link rel="canonical"[^>]*>'. (গ)curl -s -A "facebookexternalhit/1.1" URL | grep -o '<meta property="og:image"[^>]*>'. (ঘ)curl -s SITE/robots.txt | grep -i '^sitemap:'. মনে রাখবেন,grepHTML parser নয়; একই লাইনে অনেক ট্যাগ থাকলে-oলাগে, আর গুরুত্বপূর্ণ test-এ আসল HTML parser ব্যবহার করুন। শুধু নিজের সাইটে বা অনুমতি আছে এমন সাইটে চালাবেন। -
কোড সমালোচনা (নকশা): অন্তত পাঁচটি সমস্যা ধরুন। Next.js 16, root layout-এ
metadataBaseনেই।export async function generateMetadata({ params }) { const p = await getProduct(params.slug); return { title: 'বাজারঘর | ' + p.name, alternates: { canonical: '/products/' + p.slug + '?utm_source=site' }, openGraph: { images: '/og.png' }, robots: { index: true } }; }আর পাতায়<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />, সাথে robots.txt-এDisallow: /products/.params.slug: Next.js 16-এparamsPromise;await paramsলাগবে।pনা পেলেp.name-এ crash, 500.if (!p) notFound()দিন, যাতে আসল 404 আসে।- শিরোনামে হাতে “বাজারঘর | ”: layout-এর template-এর সাথে মিলে দুবার আসতে পারে, আর ব্র্যান্ড আগে বসায় পণ্যের নাম কেটে যাওয়ার ঝুঁকি। শুধু
p.nameদিন। - Canonical-এ
utm_source: tracking parameter কখনো canonical-এ নয়। metadataBaseছাড়া আপেক্ষিক canonical আর/og.png: docs অনুযায়ী build error; আর সব পাতায় একই ছবি।JSON.stringifyescape ছাড়া: বিবরণে</script>থাকলে XSS..replace(/</g, '\\u003c').- robots.txt-এ
Disallow: /products/: পণ্যের পাতাই crawl হবে না, ভেতরের সব metadata বৃথা।
-
আউটপুট অনুমান: Next.js 16. Root layout:
title: { template: '%s | বাজারঘর', default: 'বাজারঘর' },openGraph: { siteName: 'বাজারঘর', locale: 'bn_BD' }. একটি পাতা:title: 'মসুর ডাল',openGraph: { title: 'মসুর ডাল' }. আরেকটি পাতা/about: শুধুtitle: { absolute: 'আমাদের কথা' }, কোনো openGraph নেই। দুই পাতার<title>,og:title,og:site_nameকী হবে?মসুর ডালের পাতা:
<title>মসুর ডাল | বাজারঘর</title>;og:title“মসুর ডাল”;og:site_nameথাকবে না, আরog:locale-ও না, কারণ পাতারopenGraphlayout-এরটি পুরো বদলে দিয়েছে (shallow merge)./about:<title>আমাদের কথা</title>, template ছাড়া; পাতাopenGraphদেয়নি, তাই layout-এরog:site_name“বাজারঘর” আরog:locale“bn_BD” থেকে যায়। আমাদের ল্যাবের/searchপাতাতেও ঠিক এটি দেখা গেছে: পাতা openGraph দেয়নি, আর head-এ layout-এরog:site_nameএসেছে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি search engine একটি নতুন পণ্যের URL নিয়ে কী কী করে, সাধারণ ক্রমে সাজান (▲▼ চেপে সরান)
- Sitemap বা লিংক থেকে URL জানা
- robots.txt দেখে আনার অনুমতি যাচাই
- HTML আনা আর status code দেখা
- head-এর robots আর canonical পড়া
- দরকার হলে JavaScript চালিয়ে render করা
- Index-এ রাখা বা বাদ দেওয়া
Next.js 16-এর App Router-এ app/products/[slug]/page.tsx-এর generateMetadata-এ slug পড়ার সঠিক উপায় কোনটি?
const slug = params.slugconst { slug } = useParams()const { slug } = await paramsconst { slug } = router.query
Root layout-এ title: { template: '%s | বাজারঘর', default: 'বাজারঘর' }. একটি পণ্যের পাতা title: 'মসুর ডাল' দিল। HTML-এর <title> কী হবে?
- মসুর ডাল
- মসুর ডাল | বাজারঘর
- বাজারঘর
- বাজারঘর | মসুর ডাল
একটি পাতায় <meta name="robots" content="noindex"> আছে, আর robots.txt-এ সেই পথ Disallow. ফল কী হতে পারে?
- দুই স্তরের সুরক্ষা, পাতা নিশ্চিতভাবে index-এ আসবে না
- পাতাটি সাথে সাথে index থেকে মুছে যায়
- পাতাটি সবার কাছে গোপন হয়ে যায়
- Crawler noindex দেখতেই পায় না, তাই URL-টি index-এ থেকে যেতে পারে
একটি Next.js route না থাকা পণ্যে পর্দায় “পণ্যটি পাওয়া যায়নি” দেখায়, আর curl -w "%{http_code}" দেখায় 200. এটি কী, আর সমাধান কী?
- Soft 404; পণ্য না পেলে response stream শুরুর আগে
notFound()ডাকা - ভুল redirect; 301 দেওয়া
- Cache-এর সমস্যা;
Cache-Control: no-storeদেওয়া - Server error; 500 ফেরত দেওয়া
বাংলাদেশি টাকায় বিক্রি হওয়া, স্টকে থাকা একটি পণ্যের JSON-LD Offer-এর জন্য কোন জোড়া সবচেয়ে সঠিক?
priceCurrency: '৳',availability: 'স্টকে আছে'priceCurrency: 'BDT',availability: 'https://schema.org/InStock'priceCurrency: 'TK',availability: truepriceCurrency: 'Taka',availability: 'yes'
একটি বিভাগের পাতা দুই URL-এ খোলে: ?brand=teer&price=0-500 আর ?price=0-500&brand=teer&utm_source=fb. বিষয়বস্তু হুবহু এক। কোন পদক্ষেপ সবচেয়ে ভালো?
- প্রতিটি URL-এ নিজের দিকেই canonical দেওয়া
- যেটিতে
utm_sourceআছে, সেটিকে canonical করা - Tracking বাদ দিয়ে parameter সাজানো, আর filter-এর পাতায় noindex, follow ও মূল তালিকায় canonical
- দুটি URL-ই sitemap-এ যোগ করা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- যা খুঁজে পাওয়া দরকার, তা প্রথম HTML-এ। Google JavaScript render করে, কিন্তু সব bot নয়।
- Root layout-এ metadataBase আর title template; পাতায়
generateMetadata,await params, না পেলেnotFound(). Merge অগভীর। - একটি পণ্য, একটি canonical; hreflang দুই দিক থেকে; filter আর খোঁজা
noindex, follow; robots.txt-এ Disallow দিলে noindex পড়া হয় না। - JSON-LD সত্যি আর escape করা: BDT, InStock, শূন্যের বেশি দাম; বৈধতা আপনার কাজ, rich result search engine-এর সিদ্ধান্ত।
- OG ছবি চোখে দেখুন। আমাদের run-এ
ImageResponseবাংলা কার-চিহ্ন আর যুক্তাক্ষর ভুল এঁকেছে; ফন্ট নিজের প্রকল্পে রাখুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫৯ · বাংলা সাইট — i18n, font, তারিখ, সংখ্যা আর locale routingপরবর্তী পর্বএকাধিক ভাষার Next.js সাইট।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ।
- HTML & CSS · head, meta, SEO আর Open Graphভিত্তিপ্রতিটি ট্যাগের অর্থ।
- HTML & CSS · বাংলা ওয়েবেগভীরে যানUnicode, যুক্তাক্ষর আর shaping.
- Cybersecurity · XSS আর CSPপাশের ট্র্যাকJSON-LD-তে escape কেন জরুরি।