SEO & Blog Content System
Developer guide for the SEO infrastructure, blog system, and content import pipeline
Technical documentation for the site's SEO infrastructure, blog platform, and content management pipeline.
Architecture Overview
The SEO system is built on Next.js native metadata APIs and a database-driven blog platform via Prisma. There are no external SEO plugins or static-site generators involved.
app/
├── layout.tsx # Global metadata (title template, OG, Twitter, robots)
├── page.tsx # Home page metadata + JSON-LD (Organization, WebSite, SoftwareApp, FAQ)
├── robots.ts # Dynamic robots.txt generation
├── sitemap.ts # Dynamic sitemap.xml generation (static pages + DB content)
├── blog/
│ ├── layout.tsx # Blog section layout with title template
│ ├── page.tsx # Blog listing with JSON-LD Blog schema
│ └── [slug]/
│ └── page.tsx # Individual article with JSON-LD BlogPosting + Breadcrumb
├── contact-sales/
│ └── page.tsx # Contact sales metadata
└── (legal)/
├── privacy/page.tsx
└── terms/page.tsx
content/
└── blog/ # Markdown source files for pillar/curated content
└── *.md # Individual articles with YAML frontmatter
scripts/
└── import-blog.ts # Import script: content/blog/ -> database
components/
├── home/
│ └── HomeContent.tsx # Client component (extracted from page.tsx for SSR metadata)
├── contact-sales/
│ └── ContactSalesContent.tsx
└── seo-writer/ # Dashboard-based article editor (for creating content in-app)
Metadata Strategy
Global Metadata (app/layout.tsx)
The root layout sets default metadata inherited by all pages:
- Title template:
%s | Girard AI(pages provide their own title, layout appends the brand) - Description: Default for pages that don't set their own
- metadataBase: Set from
NEXT_PUBLIC_MARKETING_URLenv var (defaults tohttps://girardai.com) - Robots:
index: true, follow: truewith Google bot max-preview settings - Twitter:
summary_large_imagecard with@girardaicreator/site - Verification: Google Search Console via
GOOGLE_SITE_VERIFICATIONenv var
Page-Level Metadata
Each public-facing page exports its own metadata or generateMetadata:
| Page | Type | Key Fields |
|---|---|---|
/ (home) | Static metadata export | Title, description, keywords, canonical, OG, Twitter |
/blog | Static metadata export | Title, description, canonical, OG |
/blog/[slug] | Dynamic generateMetadata | Reads from DB: metaTitle, metaDescription, keywords, canonical, OG article fields, publishedTime |
/contact-sales | Static metadata export | Title, description, keywords, canonical |
Server Component Pattern
Pages that need both metadata exports AND client-side interactivity use this pattern:
- Server page (
app/page.tsx) exportsmetadataand renders the client component - Client component (
components/home/HomeContent.tsx) contains all interactive UI
This is necessary because Next.js metadata exports only work in server components, while "use client" components cannot export metadata.
JSON-LD Structured Data
Home Page (app/page.tsx)
Four JSON-LD schemas are injected on the home page:
| Schema | Purpose |
|---|---|
Organization | Company info, social profiles, contact point |
WebSite | Site-level search engine context |
SoftwareApplication | App category, pricing range, aggregate rating, feature list |
FAQPage | 5 common questions for rich snippet eligibility |
Blog Listing (app/blog/page.tsx)
Blogschema with nestedBlogPostingitems for all published articles
Blog Article (app/blog/[slug]/page.tsx)
BlogPostingschema with headline, description, dates, author, publisher, wordCount, keywordsBreadcrumbListschema for Home > Blog > Article navigation
robots.txt (app/robots.ts)
Generated dynamically. Rules:
| User-Agent | Rule |
|---|---|
* | Allow /, Disallow /api/, /dashboard/, /admin/, /god-mode/, auth routes, /embed/, /_next/ |
GPTBot | Disallow / |
ChatGPT-User | Disallow / |
CCBot | Disallow / |
Google-Extended | Disallow / |
References ${NEXT_PUBLIC_MARKETING_URL}/sitemap.xml.
Sitemap (app/sitemap.ts)
Dynamic generation combining:
- Static pages: Home (priority 1.0), Blog listing (0.9), Contact Sales (0.7), Privacy (0.3), Terms (0.3)
- Blog articles: Up to 1,000 published articles from DB (priority 0.8, weekly change frequency)
- Landing pages: Up to 500 published, non-noIndex pages from DB (priority 0.6, monthly)
Uses the BLOG_ORGANIZATION_ID env var to scope blog articles to a specific organization.
Blog System
Blog System and Rendering Strategy
The public blog is database-backed at runtime. Markdown files in content/blog/ are editorial source files for the import pipeline, but the live /blog and /blog/[slug] routes render from BlogArticle records in the database.
Rendering Strategy: Dynamic Server Rendering
Blog post pages do not currently use build-time static generation or on-demand ISR.
- Listing route (
app/blog/page.tsx) usesexport const dynamic = "force-dynamic"and queries published articles from Prisma at request time. - Article route (
app/blog/[slug]/page.tsx) also usesexport const dynamic = "force-dynamic"and fetches the article by slug from Prisma at request time. - Cache invalidation: there is currently no blog-specific
generateStaticParams,revalidate,revalidatePath, orrevalidateTagflow for the public blog routes.
This strategy is intentional for the current app shape:
- It avoids build-time fan-out as the content corpus grows.
- Published or updated articles become visible immediately after the database write succeeds.
- It keeps the runtime source of truth aligned with the in-app SEO Writer and the markdown import pipeline.
On-Demand ISR in Other Apps
Other Girard apps may use on-demand Incremental Static Regeneration for large markdown-backed blog estates to avoid Vercel build timeouts when pre-rendering thousands of pages.
That is not the current strategy in this repository. If we later move this app to on-demand ISR, the preferred trigger points would be article create/update/publish/archive operations, with invalidation targeting /blog and /blog/[slug].
Database Model (BlogArticle)
model BlogArticle {
id String @id @default(cuid())
organizationId String
title String
slug String
content String @db.Text // Markdown
excerpt String @db.Text
metaTitle String
metaDescription String
primaryKeyword String
secondaryKeywords String[]
headings Json // {level, text}[]
wordCount Int
readingTime Int
outline Json // {sections: {title, subsections[]}[]}
status String @default("draft") // draft | review | scheduled | published | archived
publishedAt DateTime?
seoScore Int
seoIssues Json // SEOIssue[]
readabilityScore Int
author String?
categories String[]
tags String[]
@@unique([organizationId, slug])
}
Content Sources
Blog content enters the system through two paths:
- Dashboard SEO Writer (
/components/seo-writer/) — In-app editor for creating articles. This is the primary path for scaling to 100K+ articles. - Content import pipeline (
scripts/import-blog.ts) — For bulk-importing curated markdown content fromcontent/blog/. Used for pillar content and migrations.
Content Import Pipeline
Markdown File Format
Each .md file in content/blog/ uses YAML frontmatter:
---
title: "Article Title"
slug: "article-slug"
metaTitle: "SEO Title (50-60 chars)"
metaDescription: "Meta description (150-160 chars)"
excerpt: "Brief summary for cards and listings"
primaryKeyword: "target keyword"
publishedAt: "2026-03-18T10:00:00Z"
secondaryKeywords:
- "related term 1"
- "related term 2"
categories:
- "Category"
tags:
- "tag1"
- "tag2"
seoScore: 90
readabilityScore: 85
author: "Girard AI Team"
---
Markdown content here...
Import Script
# Import all articles from content/blog/
npx tsx scripts/import-blog.ts
# Import a single article
npx tsx scripts/import-blog.ts --file article-slug
The script:
- Reads
.mdfiles fromcontent/blog/ - Parses YAML frontmatter and markdown content
- Auto-computes
wordCount,readingTime,headings, andoutlinefrom content - Upserts into the database using the
organizationId + slugcompound unique key - Requires
BLOG_ORGANIZATION_IDenv var (or uses the first org in the DB)
Adding New Pillar Content
- Create a new
.mdfile incontent/blog/following the frontmatter format above - Run
npx tsx scripts/import-blog.ts --file your-slug - The article appears at
/blog/your-slugand is auto-included in the sitemap
Blog Rendering
- Listing (
/blog): Dynamic server route. Queries published articles from Prisma, then renders the grid with categories, excerpts, dates, and reading time. - Article (
/blog/[slug]): Dynamic server route. Fetches by slug from Prisma, then renders markdown withreact-markdown+remark-gfm. Includes breadcrumb nav, tags, and CTA section. - Build behavior: Public blog routes are not pre-rendered via
generateStaticParams.
SEO Fields Per Article
| Field | Purpose | Best Practice |
|---|---|---|
metaTitle | <title> tag and OG title | 50-60 characters, include primary keyword |
metaDescription | Meta description tag | 150-160 characters, compelling with keyword |
primaryKeyword | Target search term | One specific keyword phrase |
secondaryKeywords | Supporting terms | 3-5 related phrases |
excerpt | Card text and fallback description | 1-2 sentences, engaging |
categories | Content taxonomy | 1-2 broad categories |
tags | Detailed taxonomy | 5-8 specific tags |
seoScore | Content quality indicator | 85+ is good, computed by SEO Writer |
Environment Variables
| Variable | Purpose | Default |
|---|---|---|
NEXT_PUBLIC_MARKETING_URL | Canonical public base URL for marketing metadata, sitemap, robots, and OG | https://girardai.com |
NEXT_PUBLIC_APP_URL | Runtime app URL for callbacks, webhooks, emails, and dashboard links | https://girardai.com |
BLOG_ORGANIZATION_ID | Org ID for scoping blog queries | First org in DB |
GOOGLE_SITE_VERIFICATION | Google Search Console verification code | None |
Pillar Content (Initial 10 Articles)
The initial seed content lives in content/blog/ and covers core topics:
| File | Primary Keyword | Category |
|---|---|---|
complete-guide-ai-automation-business.md | AI automation for business | AI Automation |
ai-agents-chat-voice-sms-business.md | AI agents for business | AI Agents |
build-ai-workflows-no-code.md | no-code AI workflows | Workflow Automation |
ai-customer-support-automation-guide.md | AI customer support automation | Customer Support |
multi-provider-ai-strategy-claude-gpt4-gemini.md | multi-provider AI strategy | AI Strategy |
ai-powered-sales-outreach-guide.md | AI sales outreach | Sales |
reduce-ai-costs-intelligent-model-routing.md | reduce AI costs | Cost Optimization |
enterprise-ai-security-soc2-compliance.md | enterprise AI security | Security |
ai-voice-agents-business-communication.md | AI voice agents | Voice Technology |
roi-ai-automation-business-framework.md | ROI of AI automation | Business Strategy |
All articles cross-link to each other and include CTAs to /sign-up and /contact-sales.
Extending the System
Adding a New Public Page with SEO
- Create a server component page that exports
metadata(orgenerateMetadatafor dynamic content) - Add the page to
app/sitemap.tsstatic pages array - If the page has client interactivity, extract to a client component (see Server Component Pattern above)
Adding New JSON-LD Schemas
Create a server component function that returns a <script type="application/ld+json"> tag. Reference schema.org for valid types. Add to the relevant page's render output.
Scaling to 100K+ Articles
The blog system is designed for scale:
- Database is the source of truth (not files)
- Sitemap dynamically queries up to 1,000 articles (increase
takelimit or implement sitemap index for >50K) - Public blog routes are rendered dynamically, so article count does not create build-time route fan-out
- For >50K articles, implement sitemap index splitting in
app/sitemap.ts