From f7cb93be1575329371692970525c00b8eab83719 Mon Sep 17 00:00:00 2001 From: Frank Goossen Date: Thu, 17 Jul 2025 14:02:00 +0200 Subject: [PATCH] Add SEO --- apps/dotnet/public/robots.txt | 18 ++ apps/dotnet/src/layouts/layout.astro | 80 ++++- .../src/pages/artikelen/[article].astro | 50 ++- .../src/pages/artikelen/page/[page].astro | 73 ++++- apps/dotnet/src/pages/index.astro | 56 +++- apps/dotnet/src/pages/seo-test.astro | 130 ++++++++ docs/SEO_IMPLEMENTATION.md | 291 ++++++++++++++++++ libs/ui/index.ts | 8 +- libs/ui/src/BaseHead.astro | 100 +++++- libs/ui/src/SEOTools.astro | 65 ++++ libs/ui/src/seo/index.ts | 100 ++++++ libs/ui/src/seo/structuredData.ts | 174 +++++++++++ 12 files changed, 1110 insertions(+), 35 deletions(-) create mode 100644 apps/dotnet/public/robots.txt create mode 100644 apps/dotnet/src/pages/seo-test.astro create mode 100644 docs/SEO_IMPLEMENTATION.md create mode 100644 libs/ui/src/SEOTools.astro create mode 100644 libs/ui/src/seo/index.ts create mode 100644 libs/ui/src/seo/structuredData.ts diff --git a/apps/dotnet/public/robots.txt b/apps/dotnet/public/robots.txt new file mode 100644 index 0000000..3755e82 --- /dev/null +++ b/apps/dotnet/public/robots.txt @@ -0,0 +1,18 @@ +User-agent: * +Allow: / + +# Sitemap +Sitemap: https://xprtz.net/sitemap-index.xml +Sitemap: https://xprtz.net/sitemap-0.xml + +# Crawl delay (optional - adjust based on your server capacity) +Crawl-delay: 1 + +# Disallow admin or private areas (if any) +# Disallow: /admin/ +# Disallow: /api/ + +# Allow important pages +Allow: /artikelen/ +Allow: /images/ +Allow: /fonts/ diff --git a/apps/dotnet/src/layouts/layout.astro b/apps/dotnet/src/layouts/layout.astro index 6fe52ee..93fb6a3 100644 --- a/apps/dotnet/src/layouts/layout.astro +++ b/apps/dotnet/src/layouts/layout.astro @@ -1,16 +1,80 @@ --- -import { BaseHead, Footer, Header } from '@xprtz/ui' -import Logo from '../images/logo_met_tekst.svg' -const { title, description } = Astro.props; +import { + BaseHead, + Footer, + Header, + type SEOData, + generateSEOData, + generateWebsiteStructuredData, +} from "@xprtz/ui"; +import Logo from "../images/logo_met_tekst.svg"; + +interface Props { + title: string; + description: string; + image?: string; + imageAlt?: string; + type?: "website" | "article"; + author?: string; + publishedTime?: string; + tags?: string[]; + seoData?: Partial; +} + +const { + title, + description, + image, + imageAlt, + type = "website", + author, + publishedTime, + tags, + seoData, +} = Astro.props; + +// Generate base structured data for organization/website +const baseStructuredData = generateWebsiteStructuredData({ + name: "XPRTZ", + url: Astro.site?.toString() || "https://xprtz.net", + description: + "Expert consultancy in .NET, Azure, and modern software development", + logo: `${Astro.site}${Logo.src}`, + sameAs: [ + "https://www.linkedin.com/company/xprtz", + "https://github.com/xprtz", + "https://instagram.com/workwithxprtz", + "https://www.youtube.com/channel/UCFUV8Q5RgFMwN-XM_vkwT3g", + ], +}); + +// Generate SEO data with all the props +const finalSEOData = generateSEOData({ + title: title, + description: description, + image: image, + imageAlt: imageAlt, + type: type, + siteName: "XPRTZ", + structuredData: baseStructuredData, + additionalKeywords: tags, + ...seoData, +}); + +// Override specific properties if provided +if (author) finalSEOData.author = author; +if (publishedTime) finalSEOData.publishedTime = publishedTime; +if (tags) finalSEOData.tags = tags; --- - + + - - - + + + -
+
diff --git a/apps/dotnet/src/pages/artikelen/[article].astro b/apps/dotnet/src/pages/artikelen/[article].astro index 5933901..fd2d2f5 100644 --- a/apps/dotnet/src/pages/artikelen/[article].astro +++ b/apps/dotnet/src/pages/artikelen/[article].astro @@ -1,8 +1,11 @@ --- import Layout from "../../layouts/layout.astro"; import { fetchData, type Article } from "@xprtz/cms"; -import { ContentAstro as Content } from "@xprtz/ui"; - +import { + ContentAstro as Content, + generateArticleSEOData, + generateArticleStructuredData, +} from "@xprtz/ui"; export async function getStaticPaths() { const site = import.meta.env.PUBLIC_SITE || "no-site-found"; @@ -16,7 +19,7 @@ export async function getStaticPaths() { "populate[authors][fields]": "*", "populate[authors][populate][avatar][fields][0]=url": "url", "populate[image][fields][0]": "url", - "populate[tags][fields][0]":"title", + "populate[tags][fields][0]": "title", status: "published", }, }); @@ -28,8 +31,47 @@ export async function getStaticPaths() { } const article: Article = Astro.props; + +// Environment variables +const imagesUrl = import.meta.env.PUBLIC_IMAGES_URL || ""; +const siteUrl = Astro.site?.toString() || "https://xprtz.net"; + +// Generate structured data for the article +const structuredData = generateArticleStructuredData({ + article, + siteUrl, + siteName: "XPRTZ", + imagesUrl, +}); + +// Generate comprehensive SEO data +const seoData = generateArticleSEOData({ + article, + imagesUrl, + siteName: "XPRTZ", + structuredData, +}); + +// Extract primary author info +const primaryAuthor = article.authors?.[0]; +const authorName = primaryAuthor + ? `${primaryAuthor.firstname} ${primaryAuthor.lastname}` + : undefined; + +// Extract tag names for keywords +const tagNames = article.tags?.map((tag) => tag.title) || []; --- - + diff --git a/apps/dotnet/src/pages/artikelen/page/[page].astro b/apps/dotnet/src/pages/artikelen/page/[page].astro index df00269..a9847b5 100644 --- a/apps/dotnet/src/pages/artikelen/page/[page].astro +++ b/apps/dotnet/src/pages/artikelen/page/[page].astro @@ -2,7 +2,12 @@ import type { GetStaticPaths } from "astro"; import { fetchData, type Article, type Page as PageType } from "@xprtz/cms"; import Layout from "../../../layouts/layout.astro"; -import { Container, Blogs } from "@xprtz/ui"; +import { + Container, + Blogs, + SEOTools, + generateWebsiteStructuredData, +} from "@xprtz/ui"; interface PageData { data: Article[]; @@ -44,6 +49,8 @@ export const getStaticPaths: GetStaticPaths = async ({ paginate }) => { }; const site = import.meta.env.PUBLIC_SITE || "no-site-found"; +const siteUrl = Astro.site?.toString() || "https://xprtz.net"; + const articlesPages = await fetchData>({ endpoint: "pages", wrappedByKey: "data", @@ -55,14 +62,66 @@ const articlesPages = await fetchData>({ }); const { page } = Astro.props as { page: PageData }; - const articlesPage = articlesPages[0]; + +// Generate pagination URLs +const prevPageUrl = page.url.prev ? `${siteUrl}${page.url.prev}` : undefined; +const nextPageUrl = page.url.next ? `${siteUrl}${page.url.next}` : undefined; + +// Generate structured data for the blog listing page +const structuredData = generateWebsiteStructuredData({ + name: `${articlesPage.title_website} - Pagina ${page.currentPage}`, + url: `${siteUrl}${page.url.current}`, + description: `${articlesPage.description} - Pagina ${page.currentPage} van ${page.lastPage}`, +}); + +// Generate breadcrumbs +const breadcrumbs = [ + { name: "Home", url: siteUrl }, + { name: articlesPage.title_website, url: `${siteUrl}/artikelen/` }, + ...(Number(page.currentPage) > 1 + ? [ + { + name: `Pagina ${page.currentPage}`, + url: `${siteUrl}${page.url.current}`, + }, + ] + : []), +]; + +// Create page-specific title and description +const pageTitle = + Number(page.currentPage) > 1 + ? `${articlesPage.title_website} - Pagina ${page.currentPage}` + : articlesPage.title_website; + +const pageDescription = + Number(page.currentPage) > 1 + ? `${articlesPage.description} - Pagina ${page.currentPage} van ${page.lastPage}` + : articlesPage.description; --- + + - {articlesPage.title_website} + {pageTitle} -

{articlesPage.description}

+

{pageDescription}

- +
diff --git a/apps/dotnet/src/pages/index.astro b/apps/dotnet/src/pages/index.astro index b0dc523..b7beaa6 100644 --- a/apps/dotnet/src/pages/index.astro +++ b/apps/dotnet/src/pages/index.astro @@ -1,10 +1,15 @@ --- import { fetchData, type HomePage } from "@xprtz/cms"; -import { ComponentRenderer } from "@xprtz/ui"; +import { + ComponentRenderer, + generateWebsiteStructuredData, + generateOrganizationStructuredData, +} from "@xprtz/ui"; import Layout from "../layouts/layout.astro"; const site = import.meta.env.PUBLIC_SITE || "no-site-found"; +const siteUrl = Astro.site?.toString() || "https://xprtz.net"; const homepages = await fetchData>({ endpoint: "homepages", @@ -19,7 +24,54 @@ const homepage = homepages[0]; const title = homepage.title; const description = homepage.description; + +// Generate structured data for the homepage +const websiteStructuredData = generateWebsiteStructuredData({ + name: "XPRTZ", + url: siteUrl, + description: description, + sameAs: [ + "https://www.linkedin.com/company/xprtz", + "https://github.com/xprtz", + "https://instagram.com/workwithxprtz", + "https://www.youtube.com/channel/UCFUV8Q5RgFMwN-XM_vkwT3g", + ], +}); + +// Generate Organization structured data for rich results +const organizationStructuredData = generateOrganizationStructuredData({ + name: "XPRTZ", + url: siteUrl, + logo: `${siteUrl}/images/logo.svg`, + description: + "XPRTZ is a leading .NET and Azure consulting company specializing in enterprise software development, cloud solutions, and Microsoft technology stack implementations.", + sameAs: [ + "https://www.linkedin.com/company/xprtz", + "https://github.com/xprtz", + "https://instagram.com/workwithxprtz", + "https://www.youtube.com/channel/UCFUV8Q5RgFMwN-XM_vkwT3g", + ], +}); + +// Add keywords related to your business +const keywords = [ + ".NET development", + "Azure consulting", + "Software development", + "Cloud solutions", + "Microsoft technology", + "Enterprise applications", +]; --- - + + diff --git a/apps/dotnet/src/pages/seo-test.astro b/apps/dotnet/src/pages/seo-test.astro new file mode 100644 index 0000000..dad28c6 --- /dev/null +++ b/apps/dotnet/src/pages/seo-test.astro @@ -0,0 +1,130 @@ +--- +// Test page to verify SEO implementation +import Layout from "../layouts/layout.astro"; +import { generateWebsiteStructuredData, SEOTools } from "@xprtz/ui"; + +const siteUrl = Astro.site?.toString() || "https://xprtz.net"; + +const structuredData = generateWebsiteStructuredData({ + name: "SEO Test Page - XPRTZ", + url: `${siteUrl}/seo-test`, + description: + "This is a test page to verify that all SEO elements are working correctly.", +}); + +const breadcrumbs = [ + { name: "Home", url: siteUrl }, + { name: "SEO Test", url: `${siteUrl}/seo-test` }, +]; +--- + + + + +
+

SEO Test Page

+ +
+

What to Test

+

+ This page is designed to test the SEO implementation. Here's what should + be present: +

+ +

Meta Tags

+
    +
  • ✅ Title tag with "SEO Test Page | XPRTZ"
  • +
  • ✅ Meta description
  • +
  • ✅ Meta keywords
  • +
  • ✅ Canonical URL
  • +
  • ✅ Language attribute (Dutch)
  • +
  • ✅ Theme color
  • +
+

Open Graph Tags

+
    +
  • ✅ og:title
  • +
  • ✅ og:description
  • +
  • ✅ og:type (website)
  • +
  • ✅ og:url
  • +
  • ✅ og:site_name (XPRTZ)
  • +
  • ✅ og:locale (nl_NL)
  • +
  • ✅ og:image (default placeholder)
  • +
+ +

Social Media Platforms

+

XPRTZ is active on these platforms (included in structured data):

+
    +
  • ✅ LinkedIn: /company/xprtz
  • +
  • ✅ GitHub: /xprtz
  • +
  • ✅ Instagram: @workwithxprtz
  • +
  • ✅ YouTube: UCFUV8Q5RgFMwN-XM_vkwT3g channel
  • +
+ +

Structured Data (JSON-LD)

+
    +
  • ✅ Website schema
  • +
  • ✅ Organization information
  • +
  • ✅ Breadcrumb navigation
  • +
  • ✅ Search action
  • +
+ +

Testing Tools

+

Use these tools to verify the implementation:

+ + +

How to Test

+
    +
  1. Build and deploy the website
  2. +
  3. Visit this page at: /seo-test
  4. +
  5. View page source to verify meta tags
  6. +
  7. Use the testing tools listed above
  8. +
  9. Share the page on social media to test preview cards
  10. +
+ +
+

💡 Pro Tip

+

+ Right-click on this page and select "View Source" to see all the SEO + meta tags that have been generated. You should see comprehensive Open + Graph, Twitter Card, and JSON-LD structured data in the <head> + section. +

+
+
+
+
diff --git a/docs/SEO_IMPLEMENTATION.md b/docs/SEO_IMPLEMENTATION.md new file mode 100644 index 0000000..b1fe605 --- /dev/null +++ b/docs/SEO_IMPLEMENTATION.md @@ -0,0 +1,291 @@ +# SEO Implementation Guide + +## Overview + +This document describes the comprehensive SEO implementation added to the XPRTZ website. The implementation includes enhanced meta tags, structured data, Open Graph support, and social media sharing capabilities. + +## 🎯 What Was Implemented + +### 1. Enhanced BaseHead Component + +**Location**: `libs/ui/src/BaseHead.astro` + +**Features Added**: + +- ✅ Extended Open Graph tags (site_name, locale, article-specific tags) +- ✅ Enhanced Twitter Card support (site handle, creator, image alt text) +- ✅ JSON-LD structured data support +- ✅ Additional meta tags (keywords, author, robots, theme-color) +- ✅ Article-specific meta tags (publication date, modified date, tags) +- ✅ Improved font preloading with correct MIME types + +**Key Properties**: + +```typescript +interface Props { + title: string; + description: string; + image?: string; + imageAlt?: string; + type?: "website" | "article"; + siteName?: string; + author?: string; + publishedTime?: string; + modifiedTime?: string; + tags?: string[]; + keywords?: string[]; + robots?: string; + twitterSite?: string; + twitterCreator?: string; + locale?: string; + themeColor?: string; + structuredData?: object; +} +``` + +### 2. Structured Data (JSON-LD) + +**Location**: `libs/ui/src/seo/structuredData.ts` + +**Available Functions**: + +- `generateWebsiteStructuredData()` - For homepage and general pages +- `generateOrganizationStructuredData()` - For company information +- `generateArticleStructuredData()` - For blog posts/articles +- `generatePersonStructuredData()` - For author profiles +- `generateBreadcrumbStructuredData()` - For navigation breadcrumbs + +**Example Usage**: + +```typescript +const structuredData = generateArticleStructuredData({ + article, + siteUrl: "https://xprtz.net", + siteName: "XPRTZ", + imagesUrl: "https://images.xprtz.net", +}); +``` + +### 3. SEO Helper Functions + +**Location**: `libs/ui/src/seo/index.ts` + +**Available Functions**: + +- `generateSEOData()` - Generate comprehensive SEO data object +- `generateArticleSEOData()` - Generate article-specific SEO data +- `createPageTitle()` - Create consistent page titles +- `createDescription()` - Truncate descriptions to optimal length + +### 4. SEO Tools Component + +**Location**: `libs/ui/src/SEOTools.astro` + +**Features**: + +- Pagination support (prev/next links) +- Alternate language versions +- AMP support +- Breadcrumb structured data +- Custom robots directives + +### 5. Enhanced Layouts + +**Location**: `apps/dotnet/src/layouts/layout.astro` + +**Improvements**: + +- ✅ Automatic structured data generation +- ✅ Organization information integration +- ✅ Flexible SEO data handling +- ✅ Default Dutch language support + +## 📄 Files Modified + +### Core SEO Files + +- `libs/ui/src/BaseHead.astro` - Enhanced meta tags and structured data +- `libs/ui/src/seo/structuredData.ts` - Structured data generators +- `libs/ui/src/seo/index.ts` - SEO helper functions +- `libs/ui/src/SEOTools.astro` - Additional SEO tools component +- `libs/ui/index.ts` - Export new SEO utilities + +### Implementation Files + +- `apps/dotnet/src/layouts/layout.astro` - Enhanced layout with SEO support +- `apps/dotnet/src/pages/index.astro` - Homepage with website structured data +- `apps/dotnet/src/pages/artikelen/[article].astro` - Article pages with rich SEO +- `apps/dotnet/src/pages/artikelen/page/[page].astro` - Blog listing with pagination SEO +- `apps/dotnet/public/robots.txt` - Search engine directives + +## 🔧 How to Use + +### For Homepage/General Pages: + +```astro +--- +import { generateWebsiteStructuredData } from '@xprtz/ui'; + +const structuredData = generateWebsiteStructuredData({ + name: 'XPRTZ', + url: 'https://xprtz.net', + description: 'Expert consultancy in .NET, Azure, and modern software development' +}); +--- + + + + +``` + +### For Article Pages: + +```astro +--- +import { generateArticleSEOData, generateArticleStructuredData } from '@xprtz/ui'; + +const structuredData = generateArticleStructuredData({ + article, + siteUrl: 'https://xprtz.net', + siteName: 'XPRTZ', + imagesUrl: 'https://images.xprtz.net' +}); + +const seoData = generateArticleSEOData({ + article, + imagesUrl: 'https://images.xprtz.net', + structuredData +}); +--- + + tag.title)} + seoData={seoData} +> + + +``` + +### For Pages with Special SEO Needs: + +```astro +--- +import { SEOTools } from '@xprtz/ui'; +--- + + + + + + +``` + +## 🚀 Benefits + +### Search Engine Optimization + +- **Rich Snippets**: Structured data enables rich search results +- **Better Indexing**: Proper meta tags help search engines understand content +- **Social Media**: Open Graph tags improve social media sharing +- **Performance**: Optimized font preloading and efficient meta tag delivery + +### Social Media Sharing + +- **Twitter Cards**: Properly formatted Twitter sharing +- **Facebook/LinkedIn**: Enhanced Open Graph support +- **Image Optimization**: Proper image alt text and sizing +- **Rich Previews**: Structured data for rich link previews + +### Technical SEO + +- **Canonical URLs**: Prevent duplicate content issues +- **Pagination**: Proper rel="prev/next" for paginated content +- **Breadcrumbs**: Structured data for navigation +- **Robots.txt**: Proper search engine directives +- **Sitemap**: Automatic sitemap generation (already configured) + +## 📊 Testing Your SEO + +### Tools to Test Implementation: + +1. **Google Rich Results Test**: https://search.google.com/test/rich-results +2. **Facebook Sharing Debugger**: https://developers.facebook.com/tools/debug/ +3. **Twitter Card Validator**: https://cards-dev.twitter.com/validator +4. **OpenGraph.xyz**: https://www.opengraph.xyz/ (as requested) + +### What to Test: + +- Article pages for rich snippets +- Homepage for organization markup +- Social media sharing previews +- Mobile-friendly test +- Page speed insights + +## 🔍 Next Steps + +### Potential Improvements: + +1. **FAQ Schema**: Add FAQ structured data to relevant pages +2. **Review Schema**: If you have testimonials or reviews +3. **Local Business**: If XPRTZ has physical locations +4. **Video Schema**: For any video content +5. **Course Schema**: For learning content (if applicable) + +### Monitoring: + +1. Set up Google Search Console +2. Monitor Core Web Vitals +3. Track rich snippet performance +4. Monitor social media sharing metrics + +## 🎨 Customization + +### Default Values: + +```typescript +// These can be customized in the layout or per page +const defaults = { + siteName: "XPRTZ", + locale: "nl_NL", + themeColor: "#1e40af", + robots: "index, follow", +}; + +// Social media platforms (included in structured data) +const socialMediaPlatforms = [ + "https://www.linkedin.com/company/xprtz", + "https://github.com/xprtz", + "https://instagram.com/workwithxprtz", + "https://www.youtube.com/channel/UCFUV8Q5RgFMwN-XM_vkwT3g", +]; +``` + +### Environment Variables: + +Make sure these are set in your environment: + +- `PUBLIC_SITE` - Your site identifier +- `PUBLIC_IMAGES_URL` - URL for images +- `PUBLIC_STRAPI_URL` - CMS URL + +This implementation provides a solid foundation for SEO while maintaining flexibility for future enhancements. diff --git a/libs/ui/index.ts b/libs/ui/index.ts index 8bb5790..d67b0f7 100644 --- a/libs/ui/index.ts +++ b/libs/ui/index.ts @@ -19,9 +19,14 @@ import Listing from "./src/Listing.astro"; import SubtitleWithText from "./src/SubtitleWithText.astro"; import Quote from "./src/Quote.astro"; import Blogs from "./src/Blogs.astro"; +import SEOTools from "./src/SEOTools.astro"; import ComponentRenderer from "./src/ComponentRenderer.astro"; +// SEO utilities +export * from "./src/seo/index.js"; +export * from "./src/seo/structuredData.js"; + export { BaseHead, Footer, @@ -44,5 +49,6 @@ export { SubtitleWithText, Quote, Blogs, - ComponentRenderer + SEOTools, + ComponentRenderer, }; diff --git a/libs/ui/src/BaseHead.astro b/libs/ui/src/BaseHead.astro index bcae298..461cf83 100644 --- a/libs/ui/src/BaseHead.astro +++ b/libs/ui/src/BaseHead.astro @@ -1,13 +1,41 @@ --- interface Props { - title: string; - description: string; - image?: string; + title: string; + description: string; + image?: string; + imageAlt?: string; + type?: "website" | "article"; + siteName?: string; + author?: string; + publishedTime?: string; + modifiedTime?: string; + tags?: string[]; + keywords?: string[]; + robots?: string; + locale?: string; + themeColor?: string; + structuredData?: object; } const canonicalURL = new URL(Astro.url.pathname, Astro.site); -const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props; +const { + title, + description, + image = "/blog-placeholder-1.jpg", + imageAlt = title, + type = "website", + siteName = "XPRTZ", + author, + publishedTime, + modifiedTime, + tags = [], + keywords = [], + robots = "index, follow", + locale = "nl_NL", + themeColor = "#1e40af", + structuredData, +} = Astro.props; --- @@ -15,10 +43,24 @@ const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props; + +{themeColor && } - - + + @@ -27,17 +69,49 @@ const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props; {title} +{author && } +{keywords.length > 0 && } - + + + + +{ + type === "article" && author && ( + + ) +} +{ + type === "article" && publishedTime && ( + + ) +} +{ + type === "article" && modifiedTime && ( + + ) +} +{ + type === "article" && + tags.map((tag) => ) +} - - - - - - + +{ + structuredData && + (Array.isArray(structuredData) ? ( + structuredData.map((data) => ( +