diff --git a/app/(main)/blog/[slug]/page.tsx b/app/(main)/blog/[slug]/page.tsx index 37fa9fe2..d5e41354 100644 --- a/app/(main)/blog/[slug]/page.tsx +++ b/app/(main)/blog/[slug]/page.tsx @@ -124,7 +124,6 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { articleSection: post.category.name, keywords: post.tags.join(", "), wordCount, - timeRequired: `PT${readingTime}M`, }; return ( diff --git a/app/(main)/challenges/[slug]/page.tsx b/app/(main)/challenges/[slug]/page.tsx index 7d358650..9ae1b535 100644 --- a/app/(main)/challenges/[slug]/page.tsx +++ b/app/(main)/challenges/[slug]/page.tsx @@ -51,7 +51,7 @@ export async function generateMetadata({ const challenge = await getChallengeBySlug(slug); if (!challenge) { - return {}; + return generateSEOMetadata({ noIndex: true }); } const difficultyLabels: Record = { diff --git a/app/(main)/themes/[slug]/page.tsx b/app/(main)/themes/[slug]/page.tsx index 4490fc6b..2b41f418 100644 --- a/app/(main)/themes/[slug]/page.tsx +++ b/app/(main)/themes/[slug]/page.tsx @@ -52,7 +52,7 @@ export async function generateMetadata({ const theme = await getThemeBySlug(slug); if (!theme) { - return {}; + return generateSEOMetadata({ noIndex: true }); } return generateSEOMetadata({ diff --git a/app/layout.tsx b/app/layout.tsx index 44202fc6..03418b2c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,10 +13,19 @@ import { } from "@/lib/seo"; import { TRPCReactProvider } from "@/trpc/client"; import "./globals.css"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import dynamic from "next/dynamic"; import { Toaster } from "sonner"; import { Providers } from "@/components/providers"; +const ReactQueryDevtools = + process.env.NODE_ENV === "development" + ? dynamic(() => + import("@tanstack/react-query-devtools").then((m) => ({ + default: m.ReactQueryDevtools, + })), + ) + : () => null; + export const metadata: Metadata = generateMetadata(); export const viewport: Viewport = { diff --git a/app/login/page.tsx b/app/login/page.tsx index 842d992a..a968b93d 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { headers } from "next/headers"; import Image from "next/image"; import Link from "next/link"; @@ -5,6 +6,9 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import { LoginCard } from "@/components/login-card"; import { auth } from "@/lib/auth"; +import { generateMetadata as generateSEOMetadata } from "@/lib/seo"; + +export const metadata: Metadata = generateSEOMetadata({ noIndex: true }); interface LoginPageProps { searchParams: Promise<{ next?: string }>; diff --git a/docs/seo/ACTION-PLAN.md b/docs/seo/ACTION-PLAN.md new file mode 100644 index 00000000..13bdc6be --- /dev/null +++ b/docs/seo/ACTION-PLAN.md @@ -0,0 +1,411 @@ +# SEO Action Plan — kubeasy.dev + +**Generated:** 2026-03-13 +**Overall Score:** 59 / 100 +**Target Score:** 78 / 100 (after completing Critical + High items) + +--- + +## Critical — Fix Immediately + +### C1. Remove `Disallow: /_next/*` from robots.txt +**Impact:** Crawlability — affects every page on the site +**File:** `public/robots.txt` +**Effort:** 2 minutes + +```diff +- Disallow: /_next/* +``` + +Googlebot fetches JavaScript from `/_next/static/chunks/`. Blocking it prevents full rendering of all pages. + +--- + +### C2. Create Privacy Policy and Terms of Service pages +**Impact:** E-E-A-T Trustworthiness — the largest single trust gap +**Files:** Create `app/(legal)/privacy/page.tsx` and `app/(legal)/terms/page.tsx` +**Effort:** 2–4 hours + +The app collects OAuth tokens, session cookies, user progress, and email addresses (Resend integration). Without these pages, every page on the site scores lower on Google's quality rater guidelines. Link them from the footer. + +--- + +### C3. Fix relative URLs in LearningResource and Course schemas +**Impact:** Blocks rich results for all 16 challenge pages and 9 theme pages +**File:** `lib/seo.ts` +**Effort:** 5 minutes + +```typescript +// generateLearningResourceSchema() — change: +url: `/${url}`, +// to: +url: `${siteConfig.url}/challenges/${url}`, + +// generateCourseSchema() — same fix for the url field +url: `${siteConfig.url}/themes/${url}`, +``` + +--- + +### C4. Split HeroSection into server shell + client leaf nodes +**Impact:** LCP -300–800ms on slower connections +**File:** `components/hero-section.tsx` +**Effort:** 2–3 hours + +The H1 heading is inside a `"use client"` component and doesn't appear in SSR HTML. Move the H1, paragraph, and CTA buttons to a server component. Load `TypewriterText` and `InteractiveTerminal` as dynamic client-only leaf nodes: + +```tsx +// New: hero-section.tsx (server component) +export function HeroSection() { + return ( +
+

Build it. Fix it. Learn Kubernetes.

+

...

+ + {/* client-only, below fold initially */} +
+ ) +} +``` + +--- + +### C5. Create `/public/llms.txt` +**Impact:** AI search readiness — immediate benefit for ChatGPT, Perplexity, Claude +**Effort:** 2 hours + +``` +# Kubeasy +> Free, open-source Kubernetes learning platform. Hands-on troubleshooting challenges in a local cluster. + +## Troubleshooting Guides +- https://kubeasy.dev/blog/kubernetes-crashloopbackoff-debug : Diagnose and fix CrashLoopBackOff errors +- https://kubeasy.dev/blog/kubernetes-oomkilled-fix : Fix OOMKilled pod terminations +- https://kubeasy.dev/blog/kubernetes-pod-pending-fix : Troubleshoot pods stuck in Pending state +- https://kubeasy.dev/blog/kubernetes-imagepullbackoff-fix : Fix ImagePullBackOff errors +- https://kubeasy.dev/blog/kubernetes-service-not-reachable : Debug unreachable Kubernetes services +- https://kubeasy.dev/blog/kubernetes-service-types-explained : ClusterIP vs NodePort vs LoadBalancer explained +- https://kubeasy.dev/blog/kubernetes-probes-explained : Liveness, Readiness & Startup probes guide + +## Challenges (hands-on exercises) +- https://kubeasy.dev/challenges/pod-evicted : Easy — fix a pod being evicted due to resource constraints +- https://kubeasy.dev/challenges/first-job : Easy — create your first Kubernetes Job +- https://kubeasy.dev/challenges : Full list of 16 challenges + +## Author +Paul Brissaud — DevOps/Platform Engineer, creator of Kubeasy — https://twitter.com/paulbrissaud + +## Sitemap +https://kubeasy.dev/sitemap.xml +``` + +--- + +## High — Fix Within 1 Week + +### H1. Add `noindex` to the login page +**File:** `app/login/page.tsx` +**Effort:** 5 minutes + +```typescript +export const metadata: Metadata = generateSEOMetadata({ + noIndex: true, +}) +``` + +--- + +### H2. Fix `generateMetadata` returning `{}` on challenge 404 path +**File:** `app/(main)/challenges/[slug]/page.tsx` line 53 +**Effort:** 5 minutes + +```typescript +// Change: +return {} +// To: +return generateSEOMetadata({ noIndex: true }) +``` + +--- + +### H3. Guard ReactQueryDevtools behind dev-only check +**File:** `app/layout.tsx` +**Effort:** 5 minutes + +```tsx +{process.env.NODE_ENV === 'development' && ( + +)} +``` + +Saves ~150KB of production JS for every user. + +--- + +### H4. Fix schema spec violations in `lib/seo.ts` + +**Effort:** 30 minutes total. All in `lib/seo.ts`: + +**a) Fix `SearchAction.target`** (blocks Sitelinks Search Box eligibility): +```typescript +// Change: +target: { + "@type": "EntryPoint", + urlTemplate: `${siteConfig.url}/challenges?search={search_term_string}`, +}, +// To: +target: `${siteConfig.url}/challenges?search={search_term_string}`, +``` + +**b) Fix `applicationCategory`** (invalid value): +```typescript +// Change: +applicationCategory: "DeveloperApplication", +// To: +applicationCategory: "Developer Tools", +``` + +**c) Add `url` to SoftwareApplication**: +```typescript +url: "https://www.npmjs.com/package/@kubeasy-dev/kubeasy-cli", +``` + +**d) Add `hasCourseInstance` to Course schema** (required for Course rich results): +```typescript +hasCourseInstance: { + "@type": "CourseInstance", + courseMode: "online", + courseWorkload: "PT30M", + inLanguage: "en", +}, +``` + +**e) Remove `timeRequired` from BlogPosting schema** (not a valid property — in `blog/[slug]/page.tsx`): +Remove the `timeRequired` field from the inline JSON-LD on blog post pages. + +**f) Add `@id` to Organization and WebSite schemas**: +```typescript +// Organization +"@id": `${siteConfig.url}/#organization`, + +// WebSite +"@id": `${siteConfig.url}/#website`, +``` + +--- + +### H5. Add BreadcrumbList schema to blog post pages +**File:** `app/(main)/blog/[slug]/page.tsx` +**Effort:** 20 minutes + +Call `generateBreadcrumbSchema()` from `lib/seo.ts` with the blog post breadcrumb trail and inject it as a second JSON-LD script tag alongside BlogPosting. This enables breadcrumb display in Google SERPs. + +--- + +### H6. Add at least 400 words of prose to `/get-started` +**File:** `app/(main)/get-started/page.tsx` +**Effort:** 1 hour + +The page targets high-intent queries ("learn kubernetes free") but has zero indexable text content — only an interactive widget. Add an explanation above the demo: what the user will learn, what skill is demonstrated, what happens next. This is the minimum viable content for the page to rank. + +--- + +### H7. Fix perpetual setState loops in TypewriterText and InteractiveTerminal +**Files:** `components/typewriter-text.tsx`, `components/interactive-terminal.tsx` +**Effort:** 3–4 hours + +Both components run continuous `setTimeout`-driven React renders indefinitely, generating ~10 renders/second and competing with all user interactions (INP). + +Options: +1. Replace `TypewriterText` with a CSS-only animation (`@keyframes` steps) for the typing effect — zero JS overhead +2. For `InteractiveTerminal`, use `useRef` to manage animation state without triggering re-renders; schedule updates via `requestAnimationFrame` instead of `setTimeout` +3. Add `document.addEventListener('visibilitychange')` to pause animations when tab is hidden + +--- + +### H8. Add FAQ schema and question-based headings to top 3 troubleshooting posts +**Target posts:** CrashLoopBackOff, OOMKilled, Pod Pending +**Effort:** 2 hours per article (content + schema) + +Convert declarative H2/H3 headings to interrogative form: +- "Common Causes" → "What Causes CrashLoopBackOff in Kubernetes?" +- "Step-by-Step Troubleshooting" → "How Do I Debug a CrashLoopBackOff?" + +Add `FAQPage` JSON-LD to each post with 4–6 Q&A pairs drawn from the existing content. This is the highest-correlation structural change for Google AI Overviews. + +--- + +### H9. Scope RealtimeProvider to only routes that need it +**File:** `components/providers.tsx` +**Effort:** 1 hour + +Move `` out of the global `Providers` component. Add it only to the layouts that actually need real-time features (challenge detail, dashboard). + +--- + +## Medium — Fix Within 1 Month + +### M1. Expand core troubleshooting articles to 800–1,200 words +**Target posts:** CrashLoopBackOff (366w), OOMKilled (333w), ImagePullBackOff (604w) +**Effort:** 1 day per article + +Per article, add: +- A "Real-World Example" narrative paragraph (150–200 words) +- One cited statistic from CNCF Survey or Datadog State of Kubernetes report +- Expand each "Solutions by Cause" block to be self-contained (100–150 words each) +- A "When to Escalate" section + +--- + +### M2. Add social proof to homepage +**File:** `components/stats-section.tsx` +**Effort:** 2–4 hours + +Replace the four product-attribute cards ("100% Free", "Local", "Real", "5min") with verifiable social proof. Options: +- GitHub stars count (via `https://api.github.com/repos/kubeasy-dev/kubeasy-cli` at build time) +- Total challenges started (aggregate query from database) +- "X developers learning" with a real number +- 1–2 testimonial quotes from GitHub Issues or Discord + +--- + +### M3. Add `rel="prev"` / `rel="next"` to paginated blog +**File:** `app/(main)/blog/page.tsx` +**Effort:** 30 minutes + +Use Next.js `alternates` metadata: +```typescript +alternates: { + ...(page > 1 && { prev: `/blog?page=${page - 1}` }), + ...(hasNextPage && { next: `/blog?page=${page + 1}` }), +}, +``` + +--- + +### M4. Normalize blog category URLs to lowercase +**File:** `app/sitemap.ts` line 101, `app/(main)/blog/category/[category]/` +**Effort:** 30 minutes + +```typescript +// Change: +url: `${baseUrl}/blog/category/${encodeURIComponent(category)}` +// To: +url: `${baseUrl}/blog/category/${encodeURIComponent(category.toLowerCase())}` +``` + +Ensure the route handler also normalizes the slug parameter to lowercase. + +--- + +### M5. Add `priority` to first featured blog card image +**File:** `components/blog/blog-card.tsx` +**Effort:** 15 minutes + +Add `priority` prop to the `` in the featured (large) blog card variant. This is the likely LCP element on `/blog`. + +--- + +### M6. Add Content-Security-Policy header +**File:** `next.config.ts` or `vercel.json` +**Effort:** 2–4 hours (testing required) + +Start with report-only mode to avoid breaking the site: +``` +Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://eu.i.posthog.com; +``` + +--- + +### M7. Fix static page `lastmod` in sitemap +**File:** `app/sitemap.ts` +**Effort:** 15 minutes + +Replace `new Date()` for the 5 static core pages with a stable date constant or the `VERCEL_GIT_COMMIT_SHA` environment variable mapped to a timestamp. + +--- + +### M8. Add `HowTo` schema to the homepage "How It Works" section +**File:** `lib/seo.ts`, `app/(main)/page.tsx` +**Effort:** 30 minutes + +The "From zero to Kubernetes hero in 6 simple steps" section is a canonical `HowTo` pattern. The 6 named steps with CLI commands map directly to `HowToStep` items. + +--- + +### M9. Add explicit AI crawler rules to robots.txt +**File:** `public/robots.txt` +**Effort:** 10 minutes + +``` +User-Agent: GPTBot +Allow: / + +User-Agent: OAI-SearchBot +Allow: / + +User-Agent: ClaudeBot +Allow: / + +User-Agent: PerplexityBot +Allow: / +``` + +Signals intent and may improve crawl priority by AI search engines. + +--- + +### M10. Add ItemList schema to challenge and blog listing pages +**Files:** `app/(main)/challenges/page.tsx`, `app/(main)/blog/page.tsx` +**Effort:** 1 hour + +Generate an `ItemList` with each challenge/post as a `ListItem`. This allows AI crawlers to discover all content from a single structured data entry point. + +--- + +### M11. Add Author credential fields to the data model +**File:** `types/blog.ts` +**Effort:** 1 hour (code) + populate in Notion + +Add `role`, `company`, and `certifications` fields to the `Author` type. For Kubernetes content, "Platform Engineer with CKA certification" is a primary Expertise signal. + +--- + +### M12. Fix ChallengesView Suspense fallback +**File:** `components/challenges-view.tsx` line 63 +**Effort:** 30 minutes + +Replace `
Loading...
` with a dimensioned skeleton that approximates the challenge grid layout to prevent CLS. + +--- + +## Low — Backlog + +- **L1.** Remove `Host:` directive from `robots.txt` — non-standard, ignored by Google +- **L2.** Confirm Vercel apex-only configuration — verify `https://www.kubeasy.dev` redirects to `https://kubeasy.dev` +- **L3.** Implement `generateCourseSchema()` on `/themes/[slug]` — the function exists in `lib/seo.ts` but is never called +- **L4.** Merge three JSON-LD script tags in `app/layout.tsx` into one `@graph` array +- **L5.** Remove `aggregateRating: undefined` from SoftwareApplication schema source +- **L6.** Convert `logo` in Organization schema from bare string to `ImageObject` +- **L7.** Pull `softwareVersion` from package.json at build time rather than hardcoding `"1.0.0"` +- **L8.** Add pagination safeguard in sitemap — increase post limit from 100 to 500 or add a loop +- **L9.** Implement IndexNow — ping `api.indexnow.org` on new content publication for faster Bing indexation +- **L10.** Expand the Kubeasy vs Killercoda comparison post from 352 to 1,200+ words +- **L11.** Add an About page explaining team, project origin, and motivation +- **L12.** Populate the Release Notes category with at least one post +- **L13.** Add LinkedIn to Organization `sameAs` and Author `sameAs` in schemas +- **L14.** Generate per-article Open Graph images using Next.js `ImageResponse` in `opengraph-image.tsx` +- **L15.** Audit and remove/convert `full-logo.png` (100KB PNG in `/public/` bypassing optimization) +- **L16.** Start a YouTube channel with 5-minute walkthroughs of the top troubleshooting scenarios (highest-correlation ChatGPT citation signal: 0.737) + +--- + +## Score Projection + +| Phase | Actions | Projected Score | +|---|---|---| +| Baseline | Current state | 59 / 100 | +| After Critical | C1–C5 | ~68 / 100 | +| After High | H1–H9 | ~76 / 100 | +| After Medium | M1–M12 | ~83 / 100 | +| After Low | L1–L16 | ~88 / 100 | diff --git a/docs/seo/FULL-AUDIT-REPORT.md b/docs/seo/FULL-AUDIT-REPORT.md new file mode 100644 index 00000000..326f01db --- /dev/null +++ b/docs/seo/FULL-AUDIT-REPORT.md @@ -0,0 +1,415 @@ +# SEO Full Audit Report — kubeasy.dev + +**Audit Date:** 2026-03-13 +**Site:** https://kubeasy.dev +**Business Type:** SaaS / EdTech — Kubernetes learning platform +**Audited pages:** 48 (homepage, 16 challenges, 9 themes, 15 blog posts, core pages) + +--- + +## Overall SEO Health Score: 59 / 100 + +| Category | Weight | Score | Weighted | +|---|---|---|---| +| Technical SEO | 25% | 61 / 100 | 15.3 | +| Content Quality & E-E-A-T | 25% | 54 / 100 | 13.5 | +| On-Page SEO | 20% | 65 / 100 | 13.0 | +| Schema / Structured Data | 10% | 55 / 100 | 5.5 | +| Performance (Core Web Vitals) | 10% | 60 / 100 | 6.0 | +| Images | 5% | 70 / 100 | 3.5 | +| AI Search / GEO Readiness | 5% | 54 / 100 | 2.7 | +| **Total** | | | **59.5 / 100** | + +--- + +## Top 5 Critical Issues + +1. **`Disallow: /_next/*` in robots.txt** — blocks Googlebot from fetching all JavaScript bundles, degrading rendering fidelity across every page +2. **No Privacy Policy or Terms of Service pages** — the single largest E-E-A-T trust signal gap for a platform that collects OAuth tokens, sessions, and user progress data +3. **Schema spec violations** — relative URLs in `LearningResource`/`Course` schemas, invalid `applicationCategory`, missing `hasCourseInstance` on `Course` +4. **Homepage LCP is a client-rendered H1** — `HeroSection` is a client component; the H1 heading is not in the SSR HTML, pushing LCP past JS hydration +5. **No `llms.txt`** — AI crawlers have no guided entry point; content is invisible to AI-powered search assistants + +## Top 5 Quick Wins + +1. Remove `Disallow: /_next/*` from `robots.txt` (1 line, immediate crawlability gain) +2. Add `noindex` metadata export to `app/login/page.tsx` (5 min) +3. Fix relative URLs in `lib/seo.ts` — prepend `siteConfig.url` in `generateLearningResourceSchema()` and `generateCourseSchema()` (5 min, unblocks rich results) +4. Guard `ReactQueryDevtools` behind `process.env.NODE_ENV === 'development'` in `app/layout.tsx` (1 line, ~150KB production savings) +5. Create `/public/llms.txt` with site overview, blog post list, and challenge index (2 hours, immediate AI search benefit) + +--- + +## 1. Technical SEO + +**Score: 61 / 100** + +### Crawlability + +**[CRITICAL] `Disallow: /_next/*` blocks JavaScript assets from Googlebot** +`robots.txt` blocks `/_next/*`, which is where all Next.js JS bundles live (`/_next/static/chunks/`). Googlebot renders JavaScript — this rule prevents full rendering of every page. +- File: `public/robots.txt` +- Fix: Remove `Disallow: /_next/*`. If you want to block internal data routes, use `Disallow: /_next/data/*` only. + +**[HIGH] robots.txt uses non-standard `Host:` directive** +The `Host:` directive is a Yandex-only extension; Google ignores it. Also, `http://www.kubeasy.dev` returns a 308 to `https://www.kubeasy.dev/` without confirming a further redirect to the apex domain — this creates a potential www/non-www split. +- Fix: Remove `Host:` from robots.txt. Confirm Vercel domain aliases redirect `www` to apex. + +### Indexability + +**[HIGH] `/login` page is indexable — no `noindex` directive** +`app/login/page.tsx` exports no metadata. Next.js applies the root layout's `robots: { index: true }`. The `Disallow` in robots.txt prevents crawling but not indexing of pre-discovered URLs. +- File: `app/login/page.tsx` +- Fix: Add `export const metadata = generateSEOMetadata({ noIndex: true })` + +**[MEDIUM] Challenge `generateMetadata` returns `{}` on 404 path** +When a challenge slug is not found, the metadata function returns `{}` instead of `noIndex: true`, making thin/error pages indexable. +- File: `app/(main)/challenges/[slug]/page.tsx` line 53 +- Fix: Return `generateSEOMetadata({ noIndex: true })` on not-found branch + +**[MEDIUM] Blog pagination pages lack `rel="prev"` / `rel="next"` link elements** +`/blog?page=2` sets canonical to `/blog` (correct) but emits no `rel` link elements. This makes it harder for crawlers to understand the pagination relationship. +- File: `app/(main)/blog/page.tsx` + +### Security Headers + +**[MEDIUM] No Content-Security-Policy header** +All other headers are well-configured (HSTS preload, X-Frame-Options: DENY, referrer-policy, permissions-policy). CSP is the one gap. +- Fix: Add via `headers()` in `next.config.ts` or `vercel.json` + +**[LOW] `Access-Control-Allow-Origin: *` on HTML pages** +Set by Vercel CDN on all responses. No direct SEO impact; worth reviewing if personalized content is ever served in the initial HTML. + +### Core Web Vitals (structural signals) + +**[HIGH] Above-fold `` components missing `priority` prop** +The featured blog card image on `/blog` and some above-fold images lack `priority`, so they load lazily despite being LCP candidates. +- Fix: Add `priority` to the first featured `BlogCard` image in `components/blog/blog-card.tsx` + +**[MEDIUM] Skeleton heights may not match real card dimensions** +In `app/(main)/blog/page.tsx` lines 83–87, the Suspense fallback uses `h-72 sm:h-96`. If real cards render at different heights, CLS occurs. + +**[MEDIUM] `ReactQueryDevtools` shipped to production** +`app/layout.tsx` renders `` unconditionally — ~150KB of devtools JS sent to every user. +- Fix: `{process.env.NODE_ENV === 'development' && }` + +### Structured Data (Technical perspective) + +**[MEDIUM] `softwareVersion: "1.0.0"` hardcoded in SoftwareApplication schema** +Will go stale with every CLI release. +- File: `lib/seo.ts` line 133 + +**[LOW] `generateCourseSchema()` defined but never called** +Dead code in `lib/seo.ts`; the Course schema would be valuable on `/themes/[slug]` pages. + +--- + +## 2. Content Quality & E-E-A-T + +**Score: 54 / 100** + +### Experience (12/20) +- CLI commands and the interactive terminal on homepage demonstrate genuine first-hand experience +- `ChallengeBookmark` component allows rich challenge embeds in blog posts — strong integration signal +- No user testimonials, community quotes, or verified community-scale signals anywhere + +### Expertise (15/25) +- The validation system's six check types (status, log, event, metrics, rbac, connectivity) require real Kubernetes operational knowledge +- `LearningResource` schema uses correct `timeRequired` ISO 8601 format +- **Author type lacks credential fields** — `types/blog.ts` has only `name`, `bio`, `twitter`, `github`. No `role`, `certifications`, or `company`. Even if filled in Notion, the data model cannot express professional credentials + +### Authoritativeness (10/25) +- Apache 2.0 open-source, GitHub and Twitter social links in Organization `sameAs` +- **No external citations** — no links to Kubernetes docs, CNCF, or third-party authority +- **No social proof numbers** — `stats-section.tsx` shows "100% Free", "Local", "Real", "5min" — all product attributes, not evidence of community scale +- `aggregateRating: undefined` in SoftwareApplication — no rich result eligibility + +### Trustworthiness (17/30) + +**[CRITICAL] No Privacy Policy or Terms of Service pages** +The app collects OAuth tokens, session cookies, user progress data, XP transactions, and email (Resend). No `/privacy` or `/terms` route exists in the entire `app/` directory. This is the most impactful single gap for E-E-A-T Trustworthiness. +- Footer links reference these pages but they don't exist + +**[CRITICAL] `/get-started` page has no indexable prose content** +The page title and meta description target high-intent queries ("learn kubernetes free"), but the rendered page is a pure interactive widget with zero explanatory text. Google cannot rank it. +- File: `app/(main)/get-started/page.tsx` +- Fix: Add 400+ words of explanatory content above/below the demo widget + +**[HIGH] `DEFAULT_AUTHOR` fallback masks authorship** +`lib/notion.ts` line 48–53 sets `DEFAULT_AUTHOR` to "Kubeasy Team" for posts with no Notion author relation. "Kubeasy Team" with a generic bio is the weakest possible attribution for technical troubleshooting content. + +**[HIGH] Zero social proof on homepage** +No GitHub star count, user count, challenge completion count, or testimonials. The platform is described as "community driven" with no evidence of community scale. + +**[HIGH] Release Notes category has 0 posts** +Visible, filterable category with no content creates a dead end and signals incomplete editorial commitment. + +### AI Citation Readiness (38/100) + +**[HIGH] No FAQ schema or question-based headings** +Every troubleshooting article answers a question. Headings are declarative ("Common Causes") rather than interrogative ("What Causes CrashLoopBackOff?"). FAQ schema + question-form H2/H3 is the highest-correlation structural change for AI Overviews. + +**[HIGH] Articles are too short for reliable AI citation** +- CrashLoopBackOff: 366 words +- OOMKilled: 333 words +- ImagePullBackOff: 604 words +- Pod Pending: 842 words (best) +Optimal AI-citable passage: 134–167 words. Section-level passages average 45–105 words — too short to be self-contained. + +**[MEDIUM] Word count calculation excludes code, lists, headings** +`blog/[slug]/page.tsx` lines 83–90 count only `paragraph` blocks, understating content depth by 30–50% for technical Kubernetes content heavy in code examples. + +**[MEDIUM] Challenge linking is opt-in with no enforcement** +Authors must use a challenge URL in a paragraph with CTA keywords for the rich `ChallengeBookmark` card to render. No publication check enforces at least one challenge link per post. + +--- + +## 3. Schema / Structured Data + +**Score: 55 / 100** + +### Schema Inventory + +| Page Type | Implemented | Missing | +|---|---|---| +| All pages (global) | Organization, WebSite, SoftwareApplication | @id on entities | +| `/blog/[slug]` | BlogPosting | BreadcrumbList, FAQPage | +| `/challenges/[slug]` | LearningResource, BreadcrumbList | provider, image | +| `/themes/[slug]` | Course, BreadcrumbList | hasCourseInstance | +| `/challenges`, `/blog`, `/themes` listing pages | (global only) | ItemList, BreadcrumbList | + +### Spec Violations (Blocks Rich Results) + +**[CRITICAL] Relative URLs in `LearningResource` and `Course` schemas** +`generateLearningResourceSchema()` and `generateCourseSchema()` in `lib/seo.ts` set `url` to a relative path (e.g., `/challenges/pod-evicted`). Schema.org requires absolute URLs. +- Fix: Change to `` `${siteConfig.url}/challenges/${slug}` `` + +**[CRITICAL] `applicationCategory: "DeveloperApplication"` is not a valid Schema.org value** +Valid values are strings from the iTunes taxonomy. Use `"Developer Tools"`. +- File: `lib/seo.ts` + +**[HIGH] `Course` schema missing `hasCourseInstance`** +Google's Course rich result spec requires `hasCourseInstance` with a `CourseInstance` for rich result eligibility. + +**[HIGH] `WebSite SearchAction.target` uses `EntryPoint` object** +Google's current spec requires a plain string URL template, not an `EntryPoint` wrapper. + +```json +// Current (wrong) +"target": { "@type": "EntryPoint", "urlTemplate": "https://kubeasy.dev/challenges?search={search_term_string}" } + +// Correct +"target": "https://kubeasy.dev/challenges?search={search_term_string}" +``` + +**[HIGH] `BlogPosting` schema has `timeRequired` property** +`timeRequired` is a `LearningResource` property, not a `BlogPosting` property. It will be silently ignored by Google. + +**[HIGH] `SoftwareApplication` missing `url` property** +The application has no `url` linking to the npm page or dedicated landing page. + +### Missing High-Value Schemas + +**[MEDIUM] No BreadcrumbList on blog post pages** +BreadcrumbList displays in Google SERPs and is one of the easiest rich results to qualify for. Challenge detail pages have it; blog posts don't. + +**[MEDIUM] No `@id` on Organization and WebSite schemas** +Without stable `@id` values (e.g., `https://kubeasy.dev/#organization`), Google cannot reliably disambiguate the entity across pages. Add `@id` to both global schemas and reference them from page-level schemas. + +**[LOW] `aggregateRating: undefined` in SoftwareApplication source** +Should be removed from the source object entirely rather than explicitly set to `undefined`. + +### Ready-to-Use Fix for SearchAction + +```json +{ + "@context": "https://schema.org", + "@type": "WebSite", + "@id": "https://kubeasy.dev/#website", + "name": "Kubeasy", + "url": "https://kubeasy.dev", + "potentialAction": { + "@type": "SearchAction", + "target": "https://kubeasy.dev/challenges?search={search_term_string}", + "query-input": "required name=search_term_string" + } +} +``` + +--- + +## 4. Sitemap + +**Score: 82 / 100** + +Overall: sitemap is valid and well-formed. All tested URLs return HTTP 200. No actual 404s. + +**[MEDIUM] Blog category URLs use capitalized path segments** +Sitemap includes `/blog/category/Announcements`, `/blog/category/Troubleshooting`, etc. — capital letters in URL paths are fragile and non-canonical by web convention. Root cause: `encodeURIComponent(category)` in `app/sitemap.ts` line 101 preserves Notion's capitalized category names. +- Fix: Add `.toLowerCase()` before `encodeURIComponent` and ensure the route handler matches case-insensitively + +**[LOW] Static page `lastmod` regenerates on every request** +`app/sitemap.ts` uses `new Date()` for the 5 static core pages and 4 category pages. Google will ignore lastmod it cannot trust. Use a stable hardcoded date or build-time timestamp. + +**[LOW] Blog sitemap fetches max 100 posts — silent failure above that limit** +`getBlogPosts(1, null, 100)` will silently drop posts 101+ without error. Not urgent at 11 posts but becomes critical at scale. + +**[INFO] `priority` and `changefreq` are ignored by Google** +Both fields can be removed to reduce XML size and maintenance overhead. + +--- + +## 5. Performance / Core Web Vitals + +**Score: 60 / 100** + +### What's Well-Implemented +- Vercel edge CDN: TTFB 70ms (homepage), 76ms (blog) — excellent +- PostHog proxied through same-origin `/ingest/` — no third-party connection cost +- `next/image` with AVIF/WebP formats configured throughout +- Geist fonts preloaded via HTTP `Link` response headers — no render-blocking +- Static assets at `immutable` cache, 1-year TTL +- Header Suspense boundary uses height-matched skeleton + +### Critical Issues + +**[CRITICAL] `HeroSection` is a client component — H1 not in SSR HTML** +The H1 heading on the homepage is the most likely LCP element. Because `hero-section.tsx` is `"use client"`, it does not appear in the server-rendered HTML. LCP is delayed until after JS hydration. +- File: `components/hero-section.tsx` +- Fix: Split into a server component shell (H1, paragraph, CTA buttons) + dynamic client leaf nodes for `TypewriterText` and `InteractiveTerminal` +- Expected impact: LCP -300–800ms on slower connections + +**[CRITICAL] Perpetual `setState` loops on the main thread** +`TypewriterText` schedules a new `setTimeout` on every render (80–150ms interval), creating a continuous feedback loop: timeout fires → setState → render → new timeout → repeat. `InteractiveTerminal` drives 50ms-interval updates during command typing. Together they generate ~10 React renders/second on the homepage, indefinitely, competing with all user interactions. +- Files: `components/typewriter-text.tsx`, `components/interactive-terminal.tsx` +- Fix: Rewrite with CSS animations where possible; use `useRef` to track state without re-render loops; throttle to `requestAnimationFrame` + +### High Issues + +**[HIGH] `ReactQueryDevtools` shipped to all production users (~150KB)** +`app/layout.tsx` renders `` unconditionally. +- Fix: `{process.env.NODE_ENV === 'development' && }` + +**[HIGH] `RealtimeProvider` mounted on every page** +`components/providers.tsx` wraps all children in `` from `@upstash/realtime/client`. This opens a persistent connection (WebSocket or SSE) on every page, including the homepage and blog — pages that don't need real-time features. +- Fix: Scope to only routes that use real-time (challenge status page, dashboard) + +**[HIGH] `full-logo.png` is 100KB in `/public/`** +Images in `/public/` bypass Next.js image optimization. If this image is used in any render path, it contributes ~100KB of unoptimized weight. + +### Medium Issues + +**[MEDIUM] Featured blog card image lacks `priority`** +The first (featured) blog post card on `/blog` is above the fold but uses no `priority` prop, so it loads lazily. +- File: `components/blog/blog-card.tsx` + +**[MEDIUM] `ChallengesView` has bare `
Loading...
` Suspense fallback** +If the challenge grid loads at a different height than the fallback, measurable CLS results. +- File: `components/challenges-view.tsx` line 63 + +--- + +## 6. GEO / AI Search Readiness + +**Score: 54 / 100** + +### AI Crawler Access +All AI crawlers (GPTBot, ClaudeBot, PerplexityBot, anthropic-ai) are implicitly allowed via `User-Agent: *`. No explicit rules. Functional but not optimized. + +### Critical Gaps + +**[CRITICAL] `llms.txt` does not exist** +`https://kubeasy.dev/llms.txt` returns 404. This is the primary signal for LLM crawlers to understand site content and prioritize indexing. At 11 blog posts and 16 challenges, an `llms.txt` is low-effort and high-impact. + +Sample structure for `/public/llms.txt`: +``` +# Kubeasy +> Free, open-source platform for learning Kubernetes through hands-on troubleshooting challenges. + +## Troubleshooting Guides +- https://kubeasy.dev/blog/kubernetes-crashloopbackoff-debug : Diagnose and fix CrashLoopBackOff errors +- https://kubeasy.dev/blog/kubernetes-oomkilled-fix : Fix OOMKilled pod terminations +- https://kubeasy.dev/blog/kubernetes-pod-pending-fix : Troubleshoot pods stuck in Pending state +- https://kubeasy.dev/blog/kubernetes-imagepullbackoff-fix : Fix ImagePullBackOff errors +- https://kubeasy.dev/blog/kubernetes-service-not-reachable : Debug unreachable Kubernetes services + +## Challenges +- https://kubeasy.dev/challenges/pod-evicted : Easy — fix a pod being evicted due to resource constraints +- https://kubeasy.dev/challenges/first-job : Easy — create your first Kubernetes Job + +## Author +Paul Brissaud — DevOps/Platform Engineer, creator of Kubeasy +``` + +**[HIGH] No FAQ schema — highest-correlation AI Overview signal** +Every troubleshooting post answers specific questions. Headings are declarative ("Common Causes") not interrogative ("What Causes CrashLoopBackOff?"). FAQ schema + question-form headings is the single highest-ROI structural change for Google AI Overviews and Bing Copilot. + +**[HIGH] Core troubleshooting articles are too short for reliable citation** +- CrashLoopBackOff: 366 words (minimum target: 800) +- OOMKilled: 333 words (minimum target: 800) +Optimal AI-citable passage: 134–167 words. Current section-level passages: 45–105 words. + +**[HIGH] No YouTube channel** +YouTube mention correlation with ChatGPT citation: 0.737 (strongest known signal). Zero video presence is the largest multi-modal gap. + +### Platform Scores + +| Platform | Score | Key Gap | +|---|---|---| +| Google AI Overviews | 48/100 | No FAQ schema, thin word counts | +| ChatGPT web citations | 38/100 | No llms.txt, no YouTube, weak entity graph | +| Perplexity | 52/100 | Good SSR + schema, but passages too short | +| Bing Copilot | 55/100 | BlogPosting schema helps; needs FAQ | + +### Entity Establishment +- Organization schema present with GitHub + Twitter `sameAs` — partial +- No Wikipedia/Wikidata entity +- Author (Paul Brissaud) consistently attributed across all articles — good +- Author has no `Person` schema with `knowsAbout` or Kubernetes credentials (CKA/CKAD) +- No LinkedIn in `sameAs` for either Organization or Person + +--- + +## 7. On-Page SEO Summary + +**Score: 65 / 100** + +### What's Working +- Homepage: Strong, focused title and meta description targeting "learn Kubernetes" +- Challenge pages: Titles follow `[Challenge Name] - Kubernetes Challenge | Kubeasy` pattern — descriptive and keyword-rich +- Blog posts: Descriptive titles with clear intent alignment (troubleshooting, concepts, comparisons) +- H1 tags: Present and unique on all pages +- Internal linking: Challenge Bookmark component enables rich blog-to-challenge links + +### Gaps +- `/challenges` listing: ~40 words of body text — minimal prose for a key category page +- `/blog` listing: Thin prose; mostly UI chrome around post cards +- No external outbound links to authoritative sources (Kubernetes docs, CNCF) +- No `rel="prev"` / `rel="next"` on paginated blog +- Meta descriptions on listing pages are generic and don't include numbers or specifics + +--- + +## Appendix: Source Files Referenced + +| File | Issues | +|---|---| +| `public/robots.txt` | `/_next/*` block, non-standard `Host:`, www redirect | +| `app/sitemap.ts` | `new Date()` for lastmod, category casing, 100-post limit | +| `app/layout.tsx` | ReactQueryDevtools unconditional, 3 separate JSON-LD scripts | +| `app/login/page.tsx` | Missing noindex metadata | +| `app/(main)/get-started/page.tsx` | No indexable prose content | +| `app/(main)/challenges/[slug]/page.tsx` | Empty `{}` metadata on 404 | +| `app/(main)/blog/page.tsx` | Missing rel=prev/next, skeleton height mismatch | +| `app/(main)/blog/[slug]/page.tsx` | `timeRequired` invalid on BlogPosting, no BreadcrumbList, word count undercounts | +| `lib/seo.ts` | Relative URLs in schemas, invalid `applicationCategory`, missing SearchAction fix, no hasCourseInstance | +| `lib/notion.ts` | DEFAULT_AUTHOR fallback, `isNotionConfigured` guard | +| `types/blog.ts` | Author type missing credential fields | +| `components/hero-section.tsx` | Full client component — LCP risk | +| `components/typewriter-text.tsx` | Perpetual setState loop — INP risk | +| `components/interactive-terminal.tsx` | 50ms setState loop — INP risk | +| `components/providers.tsx` | RealtimeProvider on every page | +| `components/challenges-view.tsx` | Bare Loading... Suspense fallback | +| `components/blog/blog-card.tsx` | Missing `priority` on featured card image | +| `components/stats-section.tsx` | No social proof — product attributes only | +| `config/site.ts` | `softwareVersion` hardcoded | diff --git a/lib/seo.ts b/lib/seo.ts index 71cc5f50..e2dc1007 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -119,6 +119,7 @@ export function generateSoftwareApplicationSchema() { "Command-line tool to set up local Kubernetes clusters and run hands-on challenges", applicationCategory: "DeveloperApplication", operatingSystem: "macOS, Linux, Windows", + url: "https://www.npmjs.com/package/@kubeasy-dev/kubeasy-cli", offers: { "@type": "Offer", price: "0", @@ -131,7 +132,6 @@ export function generateSoftwareApplicationSchema() { }, downloadUrl: "https://www.npmjs.com/package/@kubeasy-dev/kubeasy-cli", softwareVersion: "1.0.0", - aggregateRating: undefined, }; } @@ -142,6 +142,7 @@ export function generateOrganizationSchema() { return { "@context": "https://schema.org", "@type": "Organization", + "@id": `${siteConfig.url}/#organization`, name: siteConfig.name, description: siteConfig.description, url: siteConfig.url, @@ -157,15 +158,13 @@ export function generateWebsiteSchema() { return { "@context": "https://schema.org", "@type": "WebSite", + "@id": `${siteConfig.url}/#website`, name: siteConfig.name, description: siteConfig.description, url: siteConfig.url, potentialAction: { "@type": "SearchAction", - target: { - "@type": "EntryPoint", - urlTemplate: `${siteConfig.url}/challenges?search={search_term_string}`, - }, + target: `${siteConfig.url}/challenges?search={search_term_string}`, "query-input": "required name=search_term_string", }, }; @@ -195,10 +194,15 @@ export function generateCourseSchema({ name: provider, url: siteConfig.url, }, - url, + url: url.startsWith("http") ? url : `${siteConfig.url}${url}`, educationalLevel: "Beginner to Advanced", inLanguage: "en", isAccessibleForFree: true, + hasCourseInstance: { + "@type": "CourseInstance", + courseMode: "online", + inLanguage: "en", + }, }; } @@ -225,7 +229,7 @@ export function generateLearningResourceSchema({ "@type": "LearningResource", name, description, - url, + url: url.startsWith("http") ? url : `${siteConfig.url}${url}`, educationalLevel: difficulty, timeRequired: `PT${estimatedTime}M`, learningResourceType: "Hands-on Exercise", diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 00000000..2c65f823 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,22 @@ +# Kubeasy +> Free, open-source Kubernetes learning platform. Hands-on troubleshooting challenges in a local cluster. + +## Troubleshooting Guides +- https://kubeasy.dev/blog/kubernetes-crashloopbackoff-debug : Diagnose and fix CrashLoopBackOff errors +- https://kubeasy.dev/blog/kubernetes-oomkilled-fix : Fix OOMKilled pod terminations +- https://kubeasy.dev/blog/kubernetes-pod-pending-fix : Troubleshoot pods stuck in Pending state +- https://kubeasy.dev/blog/kubernetes-imagepullbackoff-fix : Fix ImagePullBackOff errors +- https://kubeasy.dev/blog/kubernetes-service-not-reachable : Debug unreachable Kubernetes services +- https://kubeasy.dev/blog/kubernetes-service-types-explained : ClusterIP vs NodePort vs LoadBalancer explained +- https://kubeasy.dev/blog/kubernetes-probes-explained : Liveness, Readiness & Startup probes guide + +## Challenges (hands-on exercises) +- https://kubeasy.dev/challenges/pod-evicted : Easy — fix a pod being evicted due to resource constraints +- https://kubeasy.dev/challenges/first-job : Easy — create your first Kubernetes Job +- https://kubeasy.dev/challenges : Full list of challenges + +## Author +Paul Brissaud — DevOps/Platform Engineer, creator of Kubeasy — https://twitter.com/paulbrissaud + +## Sitemap +https://kubeasy.dev/sitemap.xml diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..af1acd5a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,21 @@ +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /auth/ +Disallow: /onboarding +Disallow: /admin/ +Disallow: /login + +User-agent: GPTBot +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: PerplexityBot +Allow: / + +Sitemap: https://kubeasy.dev/sitemap.xml