A 10-person SaaS company publishes two blog posts per week but relies on a freelance writer and manual SEO checks. The founder spends 3 hours per week copy-pasting drafts into a CMS and checking keyword density. They miss internal linking opportunities and often publish posts with suboptimal meta descriptions. They need a way to automate the editorial workflow and ensure every post meets basic SEO criteria before going live.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building an Automated Blog Content Pipeline with SEO Scoring — a Next.js API that turns a topic brief (title + keywords) into a fully validated, linted, and SEO-scored blog post draft. You’ll wire six @reaatech/agents-markdown ecosystem packages together with the Vercel AI SDK to create an editorial workflow with programmatic quality gates.
By the end, you’ll have five API endpoints and a pipeline orchestrator that handles generation, markdown validation, style linting, SEO scoring, and reporting — all behind a single POST request.
Prerequisites
Node.js 22+ and pnpm 10 installed
An OpenAI API key with access to GPT models (gpt-5.2 or later)
Familiarity with Next.js 16 (App Router) and TypeScript
A basic understanding of SEO concepts (keyword density, meta descriptions, readability)
Step 1: Create the project and install dependencies
Start from an empty directory. Scaffold a Next.js 16 shell with TypeScript and the App Router:
Set up the .env.example file with the environment variables your pipeline needs:
env
# Env vars used by agnostic-blog-content-pipeline.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>LLM_MODEL=gpt-5.2NEXT_PUBLIC_APP_URL=http://localhost:3000
Copy it to .env.local and fill in your real OpenAI key:
terminal
cp .env.example .env.local
Expected output: The project scaffolding is in place and pnpm install --frozen-lockfile completes without errors. Open .env.local and replace <your-openai-key> with your actual key.
Step 2: Define the shared types and schemas
Create src/types.ts — these Zod schemas and TypeScript types are the contract that every service in the pipeline speaks:
Expected output: The file compiles. You can verify with npx tsc --noEmit --strict src/types.ts.
Step 3: Build the content generator
Create src/services/content-generator.ts. This service calls an LLM via the Vercel AI SDK, sends the topic brief as a structured prompt, and parses the JSON response into a ContentDraft:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import { type TopicBrief, type ContentDraft, ContentDraftSchema } from "../types.js";export class ContentGenerationError extends Error { constructor(message: string) { super(message); this.name = "ContentGenerationError"; }}export async function generateDraft(brief: TopicBrief): Promise<ContentDraft> { const modelId: string = process.env["LLM_MODEL"] ?? "gpt-5.2"; const systemPrompt = [ "You are a professional blog writer. Generate a blog post in JSON format.", "The JSON must have the following fields:", "- title: string (the blog post title)", "- body: string (the full blog post content in markdown format)", "- metaDescription: string (a concise SEO meta description, 120-160 characters)", "- slug: string (a URL-friendly slug derived from the title)", "- wordCount: number (approximate word count of the body)", 'Return ONLY valid JSON matching this structure, no other text.', ].join("\n"); const userPrompt = [ `Title: ${brief.title}`, `Keywords: ${brief.keywords.join(", ")}`, brief.targetAudience ? `Target Audience: ${brief.targetAudience}` : "", brief.tone ? `Tone: ${brief.tone}` : "", brief.outline ? `Outline:\n${brief.outline.map((item, i) => `${String(i + 1)}. ${item}`).join("\n")}` : "", ] .filter(Boolean) .join("\n"); const { text } = await generateText({ model: openai(modelId), system: systemPrompt, prompt: userPrompt, }); if (!text || text.trim().length === 0) { throw new ContentGenerationError("LLM returned empty response"); } let parsed: Record<string, unknown>; try { parsed = JSON.parse(text) as Record<string, unknown>; } catch { throw new ContentGenerationError( `Failed to parse LLM response as JSON: ${text.slice(0, 200)}`, ); } const result = ContentDraftSchema.safeParse(parsed); if (!result.success) { throw new ContentGenerationError( `LLM response failed validation: ${result.error.message}`, ); } return result.data;}
The system prompt instructs the model to return structured JSON, and Zod’s safeParse catches malformed responses. If the model returns invalid output, you get a clear error instead of a runtime crash downstream.
Expected output: The file compiles. You need the OPENAI_API_KEY and LLM_MODEL env vars set to run it live.
Step 4: Build the document validator and linter
Create src/services/document-validator.ts. It wraps @reaatech/agents-markdown-validator to validate a blog post’s markdown structure:
Expected output: Both files compile. The validate and runLintRules functions come from the ReaaTech packages you installed.
Step 5: Build the SEO scorer
Create src/services/seo-scorer.ts. This module evaluates a draft against four SEO dimensions and returns a composite score:
ts
import { type ContentDraft, type SeoScore } from "../types.js";function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value));}function calculateKeywordDensityScore(density: number): number { if (density >= 1 && density <= 3) return 100; if (density < 1) return clamp(density /
Expected output: The module compiles with no external dependencies beyond your project types.
Step 6: Build the reporter and observability
Create src/services/reporter.ts. This formats lint and validation results in three output formats — console, JSON, and markdown — using @reaatech/agents-markdown-reporter:
ts
import { type LintResult, type ValidationResult } from "@reaatech/agents-markdown";import { reportLintResult, reportJsonLintResult, reportMarkdownLintResult,} from "@reaatech/agents-markdown-reporter";import { reportValidationResult, reportJsonValidationResult, reportMarkdownValidationResult,} from "@reaatech/agents-markdown-reporter";export function formatLintReport( result: LintResult, format: "console" | "json" | "markdown",): string { switch (format) { case "console": { return reportLintResult(result); } case "json": { return reportJsonLintResult(result); } case "markdown": { return reportMarkdownLintResult(result); } }}export function formatValidationReport( result: ValidationResult, format: "console" | "json" | "markdown",): string { switch (format) { case "console": { return reportValidationResult(result); } case "json": { return reportJsonValidationResult(result); } case "markdown": { return reportMarkdownValidationResult(result); } }}
Create src/services/observability.ts. This captures pipeline events and measures operation timing using @reaatech/agents-markdown-observability:
Expected output: Both files compile. The observability layer silently collects timing data that you can expose via a dashboard endpoint later.
Step 7: Wire up the pipeline orchestrator
Create src/services/pipeline.ts. This is the core orchestrator that runs through each stage in sequence — generation, validation, linting, SEO scoring — collecting errors and warnings as it goes:
ts
import { type ValidationResult, type LintResult } from "@reaatech/agents-markdown";import { type TopicBrief, type ContentDraft, type SeoScore, type PipelineResult } from "../types.js";import { generateDraft, ContentGenerationError } from "./content-generator.js";import { scoreSeo } from "./seo-scorer.js";import { validateBlogPost } from "./document-validator.js";import { lintBlogPost } from "./document-linter.js";import { logPipelineEvent, timePipelineOperation } from "./observability.js";export async function runPipeline(brief: TopicBrief):
Expected output: The file compiles. Each stage is wrapped in a try/catch so a failure in one stage doesn’t kill the pipeline — it’s collected as a warning and execution continues.
Step 8: Create the API routes
All routes live under app/api/. Create each one.
app/api/health/route.ts — a simple GET health check:
ts
import { NextResponse } from "next/server";import { VERSION } from "@reaatech/agents-markdown";export function GET(): NextResponse { return NextResponse.json({ status: "ok", version: VERSION, timestamp: new Date().toISOString(), });}
app/api/generate/route.ts — accepts a topic brief, returns a generated draft:
Expected output: All five routes are discoverable under app/api/. Running npx tsc --noEmit reports zero errors.
Step 9: Create the public barrel export
Update src/index.ts to re-export everything consumers of this package will need:
ts
export { VERSION } from "@reaatech/agents-markdown";export type { TopicBrief, ContentDraft, SeoScore, PipelineResult, PipelineStage } from "./types.js";export { TopicBriefSchema, ContentDraftSchema, SeoScoreSchema, PipelineResultSchema, PipelineStageSchema } from "./types.js";export { generateDraft, ContentGenerationError } from "./services/content-generator.js";export { scoreSeo } from "./services/seo-scorer.js";export { validateBlogPost } from "./services/document-validator.js";export { lintBlogPost } from "./services/document-linter.js";export { formatLintReport, formatValidationReport } from "./services/reporter.js";export { logPipelineEvent, timePipelineOperation, getMetricsSummary } from "./services/observability.js";export { runPipeline } from "./services/pipeline.js";export { createBlogMcpServer, startBlogMcpServer } from "./mcp/index.js";
Also create src/mcp/index.ts to expose the MCP server wrapper:
ts
import { createMcpServer, startMcpServer } from "@reaatech/agents-markdown-mcp-server";export function createBlogMcpServer() { return createMcpServer();}export async function startBlogMcpServer( transportType?: "stdio" | "streamable-http",): Promise<void> { return startMcpServer(transportType);}
Expected output: The barrel compiles. You can now import { runPipeline, scoreSeo, ... } from anywhere in the project.
Step 10: Write the tests and run the suite
Create tests/seo-scorer.test.ts to cover the SEO scorer with happy-path, error, and boundary cases:
ts
import { describe, it, expect } from "vitest";import { scoreSeo } from "../src/services/seo-scorer.js";import type { ContentDraft } from "../src/types.js";function makeDraft(overrides: Partial<ContentDraft> = {}): ContentDraft { return { title: "Test", body: "# Test\n\nThis is a test body with keywords. It has multiple sentences for readability. The content is long enough to get a decent score. More words here to fill out the body. Adding even more text to reach a reasonable length.", metaDescription: "This is a test meta description that is exactly long enough to be good for SEO purposes.", slug: "test", wordCount: 50, ...overrides, };
Create tests/pipeline.test.ts to test the pipeline orchestrator with mocked services:
Create tests/api-pipeline.test.ts to test the pipeline route handler directly:
ts
import { describe, it, expect, vi } from "vitest";import { POST } from "../app/api/pipeline/route.js";import { NextRequest } from "next/server";vi.mock("ai", () => ({ generateText: vi.fn(),}));import { generateText } from "ai";describe("API /api/pipeline", () => { it("happy path — POST returns 200", async () => { vi.mocked(generateText).mockResolvedValueOnce({ text: JSON.stringify({ title: "Test Post", body: "# Test\n\nContent.\n\n[link](https://example.com)\n\n[another](https://example.org)\n\n[third](https://example.net)", metaDescription: "This is a test meta description that is exactly long enough to be good for SEO purposes.", slug: "test-post", wordCount: 50, }), } as never); const req = new NextRequest("http://localhost/api/pipeline", { method: "POST", body: JSON.stringify({ title: "Test", keywords: ["blog"] }), }); const res = await POST(req); expect(res.status).toBe(200); const body = await res.json() as { result: Record<string, unknown> }; expect(body).toHaveProperty("result"); expect(body.result).toHaveProperty("validation"); expect(body.result).toHaveProperty("lintResult"); }); it("error path — POST with empty body returns 400", async () => { const req = new NextRequest("http://localhost/api/pipeline", { method: "POST", body: JSON.stringify({}), }); const res = await POST(req); expect(res.status).toBe(400); }); it("error path — POST with invalid JSON returns 500", async () => { const req = new NextRequest("http://localhost/api/pipeline", { method: "POST", body: "not-json", }); const res = await POST(req); expect(res.status).toBe(500); }); it("boundary — POST with missing fields returns 400", async () => { const req = new NextRequest("http://localhost/api/pipeline", { method: "POST", body: JSON.stringify({ title: "No Keywords" }), }); const res = await POST(req); expect(res.status).toBe(400); });});
Create tests/api-health.test.ts for the health check endpoint:
ts
import { describe, it, expect } from "vitest";import { GET } from "../app/api/health/route.js";describe("API /api/health", () => { it("happy path — GET returns 200", () => { const res = GET(); expect(res.status).toBe(200); }); it("response body contains expected fields", async () => { const res = GET(); const body = await res.text(); expect(body).toContain('"status"'); expect(body).toContain('"version"'); expect(body).toContain('"timestamp"'); expect(body).toContain('"ok"'); });});
Now run the tests:
terminal
pnpm test
Expected output: Vitest runs all tests, reports zero failures, and coverage meets the >90% threshold across lines, branches, functions, and statements. Your terminal should show something like:
it("boundary — draft with no internal links gets zero link score", () => {
const draft = makeDraft({
body: "This is a body with no links at all. Just plain text without any markdown links. Nothing to see here.",
});
const result = scoreSeo(draft, ["test"]);
expect(result.internalLinks).toBe(0);
expect(result.internalLinksScore).toBe(0);
});
it("boundary — body with multiple sentences and good readability scores well", () => {
const draft = makeDraft({
body: "Short sentence. Another short one. Yet another. And another. More short text here. This is getting longer now. Keep adding. Still going. Almost done. Final sentence.",
it("boundary — readability calculates correctly for long sentences", () => {
const draft = makeDraft({
body: "This is a very long sentence with many words that should decrease the readability score significantly because it has more than thirty words in a single sentence without any breaks.",
});
const result = scoreSeo(draft, ["test"]);
expect(result.readabilityScore).toBeLessThan(40);
});
it("boundary — body with average sentence length 25-30 words scores 20", () => {
const draft = makeDraft({
body: "This is the first very long sentence that has exactly twenty seven words in it for testing purposes to verify that the readability score calculation works correctly for long sentences. This is the second very long sentence that has exactly twenty seven words in it for testing purposes to verify the readability score function works as expected.",
});
const result = scoreSeo(draft, ["test"]);
expect(result.readabilityScore).toBe(20);
});
it("boundary — body with only whitespace produces zero readability score", () => {
it("boundary — body with very long sentences gets minimum readability score of 10", () => {
const draft = makeDraft({
body: "This is the first extremely long sentence that has way more than thirty words in it for testing the readability score function in the SEO scorer module of the blog pipeline application to verify correct behavior. This is the second extremely long sentence that also has way more than thirty words in it for testing purposes of the readability score function in the SEO scorer module of the blog pipeline application.",
});
const result = scoreSeo(draft, ["test"]);
expect(result.readabilityScore).toBe(10);
});
});
(),
}));
import { generateText } from "ai";
import { lintBlogPost } from "../src/services/document-linter.js";
import { scoreSeo } from "../src/services/seo-scorer.js";
import { validateBlogPost } from "../src/services/document-validator.js";
const validDraftJson = {
title: "Test Post",
body: "# Test\n\nThis is a test body with keywords.\n\n[link](https://example.com)\n\n[another](https://example.org)\n\n[third](https://example.net)",
metaDescription: "This is a test meta description that is exactly long enough to be good for SEO purposes.",
slug: "test-post",
wordCount: 50,
};
describe("pipeline", () => {
beforeEach(() => {
vi.mocked(lintBlogPost).mockReturnValue({
path: "blog-post.md",
findings: [],
errorCount: 0,
warningCount: 0,
infoCount: 0,
fixableCount: 0,
});
vi.mocked(validateBlogPost).mockReturnValue({
valid: true,
type: "agents",
path: "blog-post.md",
errors: [],
warnings: [],
suggestions: [],
});
vi.mocked(scoreSeo).mockReturnValue({
keywordDensity: 2,
keywordDensityScore: 100,
metaDescriptionScore: 80,
internalLinks: 3,
internalLinksScore: 100,
readabilityScore: 80,
overallScore: 90,
warnings: [],
});
});
it("happy path — runPipeline returns complete PipelineResult with draft and seo", async () => {