The Meta Tags That Matter (And the JSON-LD That Gets You Cited)

I used to treat meta tags as an afterthought. Slap a <title> on the page, maybe add a description, call it done. Then I watched my carefully-written articles get buried in search results while thin content from bigger sites ranked above me. The difference? They spoke Google's language. I wasn't using structured data.

JSON-LD structured data and proper meta tags aren't just "nice to have" anymore. They're how you tell search engines and AI systems what your content actually means. Without them, you're trusting algorithms to guess correctly.

What Meta Tags Do

Meta tags are HTML elements that describe your page to machines. Humans never see them directly, but they determine how your page appears in search results, social shares, and AI summaries.

There are three kinds that matter: search engine metadata, social sharing metadata, and structured data.

The Non-Negotiable Search Tags

Every page needs these, full stop:

<title>Your Article Title — Site Name</title>
<meta
	name="description"
	content="A compelling 150-160 character summary that includes your primary keyword."
/>
<link rel="canonical" href="https://example.com/your-page" /><title>Your Article Title — Site Name</title>
<meta
	name="description"
	content="A compelling 150-160 character summary that includes your primary keyword."
/>
<link rel="canonical" href="https://example.com/your-page" />

The title should be under 60 characters or Google truncates it. The description won't directly affect your ranking, but it absolutely affects whether people click. And the canonical tag prevents duplicate content issues when the same content lives at multiple URLs.

Social Sharing: Open Graph and Twitter Cards

Without Open Graph tags, social platforms just guess what to show. You'll get random images and truncated titles.

<!-- Open Graph (Facebook, LinkedIn, etc.) -->
<meta property="og:title" content="Your Article Title" />
<meta property="og:description" content="Description for social shares" />
<meta property="og:image" content="https://example.com/og-image.jpg" />
<meta property="og:url" content="https://example.com/your-page" />
<meta property="og:type" content="article" />

<!-- Twitter/X Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Your Article Title" />
<meta name="twitter:description" content="Description for Twitter" />
<meta name="twitter:image" content="https://example.com/twitter-image.jpg" /><!-- Open Graph (Facebook, LinkedIn, etc.) -->
<meta property="og:title" content="Your Article Title" />
<meta property="og:description" content="Description for social shares" />
<meta property="og:image" content="https://example.com/og-image.jpg" />
<meta property="og:url" content="https://example.com/your-page" />
<meta property="og:type" content="article" />

<!-- Twitter/X Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Your Article Title" />
<meta name="twitter:description" content="Description for Twitter" />
<meta name="twitter:image" content="https://example.com/twitter-image.jpg" />

Use summary_large_image for Twitter if you have a good featured image. The regular summary card is smaller and less engaging. And yes, you still need to call them Twitter Cards — that's what the specification is named even though the company is now X.

JSON-LD (JavaScript Object Notation for Linked Data) is the format Google recommends for structured data. It's a script block that tells search engines exactly what entities are on your page.

What got me interested: pages with proper structured data get featured snippets, rich results, and knowledge panel entries. Rotten Tomatoes saw a 25% higher click-through rate after adding structured data. The Food Network got a 35% increase in visits. It actually works.

Multiple Image Aspect Ratios

Most people just slap one image in their structured data. But Google recommends multiple aspect ratios — 16:9 for rich results, 4:3 for standard displays, and 1:1 for square crops.

My BlogPosting schema handles this automatically:

schema.image = [
	`${siteBaseUrl}${post.coverImage}`, // Original (typically 16:9)
	`${siteBaseUrl}${post.coverImage.replace(/.(png|jpg|jpeg|webp)$/i, '-1x1.$1')}`, // 1:1 square
	`${siteBaseUrl}${post.coverImage.replace(/.(png|jpg|jpeg|webp)$/i, '-4x3.$1')}` // 4:3 standard
];schema.image = [
	`${siteBaseUrl}${post.coverImage}`, // Original (typically 16:9)
	`${siteBaseUrl}${post.coverImage.replace(/.(png|jpg|jpeg|webp)$/i, '-1x1.$1')}`, // 1:1 square
	`${siteBaseUrl}${post.coverImage.replace(/.(png|jpg|jpeg|webp)$/i, '-4x3.$1')}` // 4:3 standard
];

If the post doesn't have a cover image, it falls back to dynamically generated OG images with the same aspect ratio options. Your content looks right whether it appears in Google's rich results, Twitter cards, or LinkedIn shares.

ProfilePage Schema

For my homepage, I use Google's ProfilePage structured data — a relatively new schema type specifically designed for personal websites and author pages:

export function generateProfilePageSchema(postsCount?: number) {
	return {
		'@context': 'https://schema.org',
		'@type': 'ProfilePage',
		dateCreated: '2024-01-15',
		dateModified: '2026-04-11',
		mainEntity: {
			'@type': 'Person',
			name: 'Your Name',
			jobTitle: 'Software Engineer',
			image: [
				'https://example.com/photo.jpg', // 1:1 headshot
				'https://example.com/og-16x9.jpg', // 16:9 banner
				'https://example.com/og-4x3.jpg' // 4:3 alternative
			],
			sameAs: [
				'https://github.com/username',
				'https://linkedin.com/in/username',
				'https://twitter.com/username'
			],
			// This part is interesting:
			agentInteractionStatistic: {
				'@type': 'InteractionCounter',
				interactionType: 'https://schema.org/WriteAction',
				userInteractionCount: postsCount // e.g., 42
			}
		}
	};
}export function generateProfilePageSchema(postsCount?: number) {
	return {
		'@context': 'https://schema.org',
		'@type': 'ProfilePage',
		dateCreated: '2024-01-15',
		dateModified: '2026-04-11',
		mainEntity: {
			'@type': 'Person',
			name: 'Your Name',
			jobTitle: 'Software Engineer',
			image: [
				'https://example.com/photo.jpg', // 1:1 headshot
				'https://example.com/og-16x9.jpg', // 16:9 banner
				'https://example.com/og-4x3.jpg' // 4:3 alternative
			],
			sameAs: [
				'https://github.com/username',
				'https://linkedin.com/in/username',
				'https://twitter.com/username'
			],
			// This part is interesting:
			agentInteractionStatistic: {
				'@type': 'InteractionCounter',
				interactionType: 'https://schema.org/WriteAction',
				userInteractionCount: postsCount // e.g., 42
			}
		}
	};
}

The agentInteractionStatistic with WriteAction tells Google how many posts you've written. When someone searches for your name, they might see "42 posts" in your knowledge panel. That's social proof in search results.

BlogPosting Schema: Author and Publisher

Google has specific guidelines for authorship markup. You need both an author (the Person who wrote it) and a publisher (the Organization responsible for it). Even on a personal blog, you're both:

export function generateBlogPostingSchema(post: Post) {
	return {
		'@context': 'https://schema.org',
		'@type': 'BlogPosting',
		headline: post.title,
		description: post.description,
		author: {
			'@type': 'Person',
			name: authorName,
			url: siteBaseUrl,
			sameAs: authorSameAs // Links to GitHub, LinkedIn, etc.
		},
		publisher: {
			'@type': 'Organization',
			name: authorName,
			url: siteBaseUrl
		},
		datePublished: '2026-04-11T08:00:00+00:00',
		dateModified: '2026-04-11T10:00:00+00:00',
		// ISO 8601 duration format for reading time
		timeRequired: 'PT8M' // 8 minutes
	};
}export function generateBlogPostingSchema(post: Post) {
	return {
		'@context': 'https://schema.org',
		'@type': 'BlogPosting',
		headline: post.title,
		description: post.description,
		author: {
			'@type': 'Person',
			name: authorName,
			url: siteBaseUrl,
			sameAs: authorSameAs // Links to GitHub, LinkedIn, etc.
		},
		publisher: {
			'@type': 'Organization',
			name: authorName,
			url: siteBaseUrl
		},
		datePublished: '2026-04-11T08:00:00+00:00',
		dateModified: '2026-04-11T10:00:00+00:00',
		// ISO 8601 duration format for reading time
		timeRequired: 'PT8M' // 8 minutes
	};
}

The sameAs array matters — it connects your content to your established presence on GitHub, LinkedIn, Twitter, etc. Google uses these E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) signals for ranking.

FAQ Schema for "People Also Ask"

If you want to capture featured snippets, FAQ schema is your best bet:

export function generateFAQSchema(items: FAQItem[], post: Post) {
	return {
		'@context': 'https://schema.org',
		'@type': 'FAQPage',
		mainEntity: items.map((item) => ({
			'@type': 'Question',
			name: item.question,
			acceptedAnswer: {
				'@type': 'Answer',
				text: item.answer
			}
		}))
	};
}export function generateFAQSchema(items: FAQItem[], post: Post) {
	return {
		'@context': 'https://schema.org',
		'@type': 'FAQPage',
		mainEntity: items.map((item) => ({
			'@type': 'Question',
			name: item.question,
			acceptedAnswer: {
				'@type': 'Answer',
				text: item.answer
			}
		}))
	};
}

Keep answers to 40-60 words. Longer answers get truncated or ignored by Google's snippet extraction. I store FAQ items in the post frontmatter and render them both visually on the page and invisibly in the JSON-LD.

Putting It All Together: The Complete SEO Component

I combine everything in a SvelteKit SEO component using $derived to reactively build metadata from props, then render it server-side so crawlers see it immediately:

<script lang="ts">
	let { title, description, post, breadcrumbs, faq, readingTime } = $props();

	// Reactively generate schemas whenever props change
	let blogPostingSchema = $derived(post ? generateBlogPostingSchema(post) : null);
	let breadcrumbSchema = $derived(
		breadcrumbs?.length ? generateBreadcrumbSchema(breadcrumbs) : null
	);
	let faqSchema = $derived(faq?.length && post ? generateFAQSchema(faq, post) : null);

	// Combine all structured data into one array
	let allStructuredData = $derived([
		...(blogPostingSchema ? [blogPostingSchema] : []),
		...(breadcrumbSchema ? [breadcrumbSchema] : []),
		...(faqSchema ? [faqSchema] : [])
	]);
</script>

<svelte:head>
	<title>{pageTitle}</title>
	<meta name="description" content={description} />
	<link rel="canonical" href={canonicalUrl} />

	<!-- Open Graph with multiple images -->
	{#if ogImages?.length}
		{#each ogImages as img}
			<meta property="og:image" content={img.url} />
			<meta property="og:image:width" content={img.width} />
			<meta property="og:image:height" content={img.height} />
		{/each}
	{/if}

	<!-- Twitter with reading time labels -->
	<meta name="twitter:card" content="summary_large_image" />
	<meta name="twitter:label1" content="Written by" />
	<meta name="twitter:data1" content={authorName} />
	<meta name="twitter:label2" content="Est. reading time" />
	<meta name="twitter:data2" content={readingTimeStr} />

	<!-- JSON-LD rendered server-side -->
	{#each allStructuredData as data}
		{@html `<script type="application/ld+json">${JSON.stringify(data)}</script>`}
	{/each}
</svelte:head><script lang="ts">
	let { title, description, post, breadcrumbs, faq, readingTime } = $props();

	// Reactively generate schemas whenever props change
	let blogPostingSchema = $derived(post ? generateBlogPostingSchema(post) : null);
	let breadcrumbSchema = $derived(
		breadcrumbs?.length ? generateBreadcrumbSchema(breadcrumbs) : null
	);
	let faqSchema = $derived(faq?.length && post ? generateFAQSchema(faq, post) : null);

	// Combine all structured data into one array
	let allStructuredData = $derived([
		...(blogPostingSchema ? [blogPostingSchema] : []),
		...(breadcrumbSchema ? [breadcrumbSchema] : []),
		...(faqSchema ? [faqSchema] : [])
	]);
</script>

<svelte:head>
	<title>{pageTitle}</title>
	<meta name="description" content={description} />
	<link rel="canonical" href={canonicalUrl} />

	<!-- Open Graph with multiple images -->
	{#if ogImages?.length}
		{#each ogImages as img}
			<meta property="og:image" content={img.url} />
			<meta property="og:image:width" content={img.width} />
			<meta property="og:image:height" content={img.height} />
		{/each}
	{/if}

	<!-- Twitter with reading time labels -->
	<meta name="twitter:card" content="summary_large_image" />
	<meta name="twitter:label1" content="Written by" />
	<meta name="twitter:data1" content={authorName} />
	<meta name="twitter:label2" content="Est. reading time" />
	<meta name="twitter:data2" content={readingTimeStr} />

	<!-- JSON-LD rendered server-side -->
	{#each allStructuredData as data}
		{@html `<script type="application/ld+json">${JSON.stringify(data)}</script>`}
	{/each}
</svelte:head>

The {@html} tag is necessary because Svelte escapes script content by default. Since the data is server-generated, this is safe.

Using the Component

In your +page.svelte, pass the data from your loader:

<script>
	import SEO from '@components/SEO/index.svelte';

	let { data } = $props();
</script>

<SEO
	title={data.post.title}
	description={data.post.description}
	post={data.post}
	faq={data.post.faq}
	readingTime={data.readingTime}
/><script>
	import SEO from '@components/SEO/index.svelte';

	let { data } = $props();
</script>

<SEO
	title={data.post.title}
	description={data.post.description}
	post={data.post}
	faq={data.post.faq}
	readingTime={data.readingTime}
/>

That's it. The component generates all the meta tags, OG images, and JSON-LD automatically. Since it renders in svelte:head during SSR, crawlers see the complete markup without waiting for JavaScript.

When you build for Cloudflare Workers (pnpm build), the output in .svelte-kit/cloudflare/ contains static HTML with all the JSON-LD already injected. No client-side rendering required — search engine crawlers get the structured data on the first request.

A note on tooling: Google Search Console's Rich Results Test has a caching layer that can make results inconsistent — run it twice and you may get different output. More importantly, the schema.org documentation lists hundreds of properties, but only a fraction are recognized by Google. Use the Google Search Central documentation as your source of truth — it's what actually triggers rich results.

AI SEO: Why This Matters More Now

Traditional SEO gets you ranked. AI SEO gets you cited.

ChatGPT, Perplexity, and Google's AI Overviews pull from structured data when generating answers. If your content isn't marked up correctly, AI systems can't extract it cleanly. They'll cite your competitors instead.

According to Princeton's Generative Engine Optimization research, pages with proper citations and statistics get cited 40% more often by AI systems. Pages with clear structure and schema markup see 30-40% higher visibility. The same JSON-LD that gets you rich snippets also makes your content extractable for AI answers.

Testing Tools

Don't just add markup and hope. Test it:

Run the Rich Results Test twice. Google's cache can lag, and the second run is often more accurate than the first.

Image Dimensions

Get the dimensions wrong and you'll end up with blurry crops or platforms ignoring your images entirely.

PlatformOptimal SizeAspect RatioNotes
Twitter/X1200×600 or 1200×12002:1 or 1:1Large summary card prefers 2:1, but 1:1 also works
Facebook1200×6301.91:1If you only make one OG image, make this size
LinkedIn1200×6271.91:1Similar to Facebook, but slightly different
Google1200×67516:9For rich results and Discover feed

Minimums to avoid rejection:

  • Twitter: 144×144 (they upscale, but it looks bad)
  • Facebook: 200×200 (anything smaller gets ignored)
  • Google rich results: 120×120 absolute minimum, 600×600 recommended

FAQ

Q Do I really need multiple image sizes in structured data?
A Not strictly, but Google explicitly recommends it. The different aspect ratios let Google choose the best crop for different contexts — knowledge panels vs. rich results vs. Discover feed.
Q What is the difference between BlogPosting and Article schema?
A Functionally identical for Google. BlogPosting is more specific to blog content. I use BlogPosting for posts, Article for more formal content. Pick one and be consistent.
Q Does the agentInteractionStatistic actually show up in search results?
A Sometimes. It is relatively new and not guaranteed, but when it works, you get "42 posts" displayed in your knowledge panel. Worth including.
Q Can I have multiple JSON-LD scripts on one page?
A Yes, though Google recommends combining them. My component generates separate schemas for the post, breadcrumbs, and FAQ, then renders them all. Works fine.
Q What is the PT format in timeRequired?
A ISO 8601 duration format. PT8M means "Period of Time: 8 Minutes". PT1H30M would be an hour and a half.
Q Will blocking AI bots in robots.txt prevent my structured data from working?
A Yes. If you block GPTBot, PerplexityBot, or ClaudeBot, those AI systems cannot access your content to cite it. Traditional search crawling is separate, but AI citations require access.
About the Author

Asaduzzaman Pavel

Software Engineer who actually enjoys the friction of well-architected systems. 15+ years building high-performance backends and infrastructure that handles real-world chaos at scale.

Open to new opportunities

Comments

  • Sign in with GitHub to comment
  • Keep it respectful and on-topic
  • No spam or self-promotion