diff --git a/data/blog-posts.json b/data/blog-posts.json new file mode 100644 index 00000000..45ec2ee8 --- /dev/null +++ b/data/blog-posts.json @@ -0,0 +1,57 @@ +[ + { + "title": "How We Turned Prompt Chaos into an Agent Runbook", + "slug": "prompt-chaos-to-agent-runbook", + "content": "# The problem\n\nIn early agent teams, every workflow starts as tribal knowledge in chat threads. That breaks quickly once multiple agents are shipping in parallel.\n\n# What changed\n\nWe moved from ad-hoc prompts to a runbook format with clear sections: context, constraints, expected output, and rollback steps.\n\n# Why it worked\n\nAgents became more reliable because each task had explicit boundaries. New contributors could onboard by reading one file instead of reverse-engineering old conversations.\n\n# Practical tip\n\nIf a task fails twice, write the runbook before retrying. It pays for itself immediately.", + "author": "Kai @ Team Reflectt", + "tags": ["agent-engineering", "runbooks", "operations"], + "publishedAt": "2026-02-02T16:00:00.000Z", + "updatedAt": "2026-02-02T16:00:00.000Z", + "excerpt": "How structured runbooks replaced fragile prompt threads and improved multi-agent execution reliability.", + "readingTime": "4 min read" + }, + { + "title": "Designing API Contracts That Agents Can Actually Use", + "slug": "designing-agent-friendly-api-contracts", + "content": "# API docs are not enough\n\nMost APIs are written for humans. Agents need machine-consistent responses, predictable errors, and low-ambiguity naming.\n\n# The contract checklist\n\n1. Stable field names\n2. Typed error payloads\n3. Idempotent writes where possible\n4. Search and filter parameters that compose cleanly\n\n# Better outcomes\n\nOnce we standardized payload shapes across endpoints, agent recovery logic got simpler and retries dropped.\n\n# What to avoid\n\nAvoid returning strings that mix multiple data points. Agents should not parse prose to do basic work.", + "author": "Mina Park", + "tags": ["api", "agent-interop", "backend"], + "publishedAt": "2026-01-28T18:30:00.000Z", + "updatedAt": "2026-01-28T18:30:00.000Z", + "excerpt": "A practical checklist for writing predictable APIs that autonomous agents can integrate without brittle parsing.", + "readingTime": "5 min read" + }, + { + "title": "State, Memory, and Why Your Agent Keeps Repeating Itself", + "slug": "state-memory-agent-repetition", + "content": "# Symptom\n\nYour agent restarts and redoes work it already completed. That is usually a memory design issue, not a model quality issue.\n\n# Minimal memory layers\n\nUse three layers: ephemeral task state, session memory, and durable project memory. Each layer has different retention and trust rules.\n\n# Implementation pattern\n\nStore canonical decisions in files, not only in conversation history. Use explicit timestamps and ownership for every persisted note.\n\n# Result\n\nAgents stop looping, recover from interruptions faster, and collaborate with fewer duplicated actions.", + "author": "Aisha Bennett", + "tags": ["memory", "state-management", "multi-agent"], + "publishedAt": "2026-01-22T14:00:00.000Z", + "updatedAt": "2026-01-22T14:00:00.000Z", + "excerpt": "A layered memory model that prevents repetitive agent behavior and improves interruption recovery.", + "readingTime": "6 min read" + }, + { + "title": "Shipping Multi-Agent Features with Safety Guardrails", + "slug": "multi-agent-features-safety-guardrails", + "content": "# Move fast, with brakes\n\nAutonomous execution is powerful, but unsafe defaults can create costly incidents. Guardrails are feature infrastructure, not optional policy text.\n\n# Guardrails we enforce\n\n- Rate limits on write endpoints\n- Structured validation before tool execution\n- Human approval for destructive actions\n- Audit logs for each high-impact step\n\n# Team impact\n\nGuardrails reduced incident response time and increased trust from both developers and operators.\n\n# Rule of thumb\n\nIf a human would ask for confirmation, your agent should too.", + "author": "Noah Sinclair", + "tags": ["safety", "governance", "production"], + "publishedAt": "2026-01-15T20:15:00.000Z", + "updatedAt": "2026-01-15T20:15:00.000Z", + "excerpt": "The operational safety controls that let multi-agent systems ship quickly without avoidable incidents.", + "readingTime": "5 min read" + }, + { + "title": "From Single Agent to Agent Team: A Migration Playbook", + "slug": "single-agent-to-agent-team-migration", + "content": "# Why teams beat monoliths\n\nSingle agents become bottlenecks as scope grows. Splitting work by role improves throughput and observability.\n\n# Migration steps\n\n1. Identify repeatable responsibilities\n2. Create role-specific prompts and tool scopes\n3. Add orchestration with clear handoff contracts\n4. Measure latency and failure reasons per role\n\n# Common mistake\n\nDo not add more agents before defining ownership. Ambiguity creates duplicate work and silent failures.\n\n# Final takeaway\n\nA small, well-defined team of agents outperforms one overloaded generalist agent in most production workflows.", + "author": "Eli Navarro", + "tags": ["orchestration", "migration", "architecture"], + "publishedAt": "2026-01-09T12:45:00.000Z", + "updatedAt": "2026-01-09T12:45:00.000Z", + "excerpt": "A step-by-step migration path for evolving from one overloaded agent to a reliable multi-agent team.", + "readingTime": "7 min read" + } +] diff --git a/src/app/api/blog/[slug]/route.ts b/src/app/api/blog/[slug]/route.ts new file mode 100644 index 00000000..bacf39ad --- /dev/null +++ b/src/app/api/blog/[slug]/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { readBlogPosts } from "@/lib/blog"; + +interface BlogSlugRouteContext { + params: Promise<{ slug: string }>; +} + +export async function GET(_request: Request, context: BlogSlugRouteContext) { + const { slug } = await context.params; + const posts = await readBlogPosts(); + const post = posts.find((p) => p.slug === slug); + + if (!post) { + return NextResponse.json({ error: "Post not found" }, { status: 404 }); + } + + return NextResponse.json(post); +} diff --git a/src/app/api/blog/route.ts b/src/app/api/blog/route.ts new file mode 100644 index 00000000..1c295aa0 --- /dev/null +++ b/src/app/api/blog/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + buildExcerpt, + estimateReadingTime, + filterAndSortPosts, + readBlogPosts, + writeBlogPosts, + type BlogPost, +} from "@/lib/blog"; + +type CreateBlogRequest = { + title?: unknown; + slug?: unknown; + content?: unknown; + author?: unknown; + tags?: unknown; +}; + +function validateCreate(body: CreateBlogRequest): string[] { + const errors: string[] = []; + + if (typeof body.title !== "string" || !body.title.trim()) { + errors.push("title is required"); + } + + if (typeof body.slug !== "string" || !body.slug.trim()) { + errors.push("slug is required"); + } + + if (typeof body.content !== "string" || !body.content.trim()) { + errors.push("content is required"); + } + + if (typeof body.author !== "string" || !body.author.trim()) { + errors.push("author is required"); + } + + if (!Array.isArray(body.tags) || body.tags.length === 0 || !body.tags.every((t) => typeof t === "string")) { + errors.push("tags must be a non-empty string array"); + } + + return errors; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || undefined; + const tag = searchParams.get("tag") || undefined; + const sort = searchParams.get("sort") || undefined; + + const posts = await readBlogPosts(); + const filtered = filterAndSortPosts(posts, { search, tag, sort }); + + return NextResponse.json({ posts: filtered, total: filtered.length }); +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as CreateBlogRequest; + const errors = validateCreate(body); + + if (errors.length > 0) { + return NextResponse.json({ error: "Validation failed", details: errors }, { status: 400 }); + } + + const posts = await readBlogPosts(); + const slug = (body.slug as string).trim(); + + if (posts.some((p) => p.slug === slug)) { + return NextResponse.json({ error: "A post with this slug already exists" }, { status: 409 }); + } + + const content = (body.content as string).trim(); + const now = new Date().toISOString(); + + const newPost: BlogPost = { + title: (body.title as string).trim(), + slug, + content, + author: (body.author as string).trim(), + tags: (body.tags as string[]).map((t) => t.trim()).filter(Boolean), + publishedAt: now, + updatedAt: now, + excerpt: buildExcerpt(content), + readingTime: estimateReadingTime(content), + }; + + posts.push(newPost); + await writeBlogPosts(posts); + + return NextResponse.json(newPost, { status: 201 }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 528b2fd7..dd90fc44 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,30 +1,90 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import type { Metadata } from 'next'; -import { getBlogPostBySlug, getRelatedBlogPosts, getBlogPosts } from '@/lib/data'; -import { Badge } from '@/components/ui/badge'; -import { ShareButtons } from '@/components/blog/share-buttons'; -import { Separator } from '@/components/ui/separator'; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { Badge } from "@/components/ui/badge"; +import { ShareButtons } from "@/components/blog/share-buttons"; +import { Separator } from "@/components/ui/separator"; +import type { BlogListResponse, BlogPost } from "@/lib/blog"; interface BlogPostPageProps { - params: { + params: Promise<{ slug: string; - }; + }>; } -export async function generateStaticParams() { - const posts = getBlogPosts(); - return posts.map((post) => ({ - slug: post.slug, - })); +async function getBaseUrl(): Promise { + const hdrs = await headers(); + const host = hdrs.get("x-forwarded-host") || hdrs.get("host"); + const protocol = hdrs.get("x-forwarded-proto") || "https"; + const fallbackBase = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + + return host ? `${protocol}://${host}` : fallbackBase; +} + +async function fetchBlogPost(slug: string): Promise { + const base = await getBaseUrl(); + const response = await fetch(`${base}/api/blog/${slug}`, { + next: { revalidate: 300 }, + }); + + if (!response.ok) { + return null; + } + + return (await response.json()) as BlogPost; +} + +async function fetchBlogPosts(): Promise { + const base = await getBaseUrl(); + const response = await fetch(`${base}/api/blog?sort=recent`, { + next: { revalidate: 300 }, + }); + + if (!response.ok) { + return []; + } + + const data = (await response.json()) as BlogListResponse; + return data.posts; +} + +function getRelatedPosts(currentPost: BlogPost, posts: BlogPost[], limit = 3): BlogPost[] { + const scored = posts + .filter((p) => p.slug !== currentPost.slug) + .map((p) => { + const sharedTags = p.tags.filter((tag) => currentPost.tags.includes(tag)); + return { post: p, score: sharedTags.length }; + }) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return new Date(b.post.publishedAt).getTime() - new Date(a.post.publishedAt).getTime(); + }); + + return scored.slice(0, limit).map((s) => s.post); +} + +function markdownToBlocks(markdown: string): Array<{ type: "heading" | "paragraph"; text: string }> { + return markdown + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + if (line.startsWith("# ")) { + return { type: "heading" as const, text: line.slice(2).trim() }; + } + + return { type: "paragraph" as const, text: line }; + }); } export async function generateMetadata({ params }: BlogPostPageProps): Promise { - const post = getBlogPostBySlug(params.slug); + const { slug } = await params; + const post = await fetchBlogPost(slug); if (!post) { return { - title: 'Post Not Found', + title: "Post Not Found", }; } @@ -35,130 +95,90 @@ export async function generateMetadata({ params }: BlogPostPageProps): Promise { - const colors: Record = { - Engineering: 'bg-cyan/10 text-cyan border-cyan/30', - Product: 'bg-purple/10 text-purple border-purple/30', - Community: 'bg-green-500/10 text-green-500 border-green-500/30', - Tutorials: 'bg-blue-500/10 text-blue-500 border-blue-500/30', - 'Case Studies': 'bg-orange-500/10 text-orange-500 border-orange-500/30', - }; - return colors[category] || 'bg-white/5 text-white/60 border-white/10'; - }; + const relatedPosts = getRelatedPosts(post, await fetchBlogPosts()); + const contentBlocks = markdownToBlocks(post.content); const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", }); }; return (
- {/* Back Link */} - - - + + + Back to Blog - {/* Category Badge */} -
- - {post.category} - -
- - {/* Title */} -

- {post.title} -

+

{post.title}

- {/* Meta Information */}
{formatDate(post.publishedAt)} {post.readingTime}
- {/* Author Info */}
- {post.author.name.charAt(0)} + {post.author.charAt(0)}
-
{post.author.name}
-
{post.author.role}
+
{post.author}
+
Author
- {/* Article Content */}
- {post.content.map((block, index) => { - if (block.type === 'heading') { + {contentBlocks.map((block, index) => { + if (block.type === "heading") { return ( -

+

{block.text}

); } + return ( -

+

{block.text}

); })}
- {/* Tags */} {post.tags.length > 0 && (
{post.tags.map((tag) => ( - + {tag} ))} @@ -168,37 +188,13 @@ export default function BlogPostPage({ params }: BlogPostPageProps) { - {/* Share Buttons */}
-

- Share this post -

- +

Share this post

+
- {/* Author Bio Section */} -
-

- About the Author -

-
-
- {post.author.name.charAt(0)} -
-
-
{post.author.name}
-
{post.author.role}
-

{post.author.bio}

-
-
-
- - {/* Related Posts */} {relatedPosts.length > 0 && (

Related Posts

@@ -209,21 +205,22 @@ export default function BlogPostPage({ params }: BlogPostPageProps) { href={`/blog/${relatedPost.slug}`} className="group bg-card/50 border border-white/5 rounded-lg p-4 hover:border-cyan/20 transition-all" > - - {relatedPost.category} - +
+ {relatedPost.tags.slice(0, 1).map((tag) => ( + + {tag} + + ))} +

{relatedPost.title}

-

- {relatedPost.excerpt} -

-
- {formatDate(relatedPost.publishedAt)} -
+

{relatedPost.excerpt}

+
{formatDate(relatedPost.publishedAt)}
))}
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 1109bef5..d36f56aa 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,22 +1,43 @@ -import type { Metadata } from 'next'; -import { getBlogPosts, getBlogCategories } from '@/lib/data'; -import { BlogGrid } from '@/components/blog/blog-grid'; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { BlogGrid } from "@/components/blog/blog-grid"; +import type { BlogListResponse } from "@/lib/blog"; export const metadata: Metadata = { title: "Blog — forAgents.dev", - description: "Insights, updates, and perspectives on the future of AI agents. Explore guides, technical deep-dives, and announcements.", + description: + "Insights, updates, and perspectives on the future of AI agents. Explore guides, technical deep-dives, and announcements.", openGraph: { title: "Blog — forAgents.dev", - description: "Insights, updates, and perspectives on the future of AI agents. Explore guides, technical deep-dives, and announcements.", + description: + "Insights, updates, and perspectives on the future of AI agents. Explore guides, technical deep-dives, and announcements.", url: "https://foragents.dev/blog", siteName: "forAgents.dev", type: "website", }, }; -export default function BlogPage() { - const posts = getBlogPosts(); - const categories = getBlogCategories(); +async function fetchBlogPosts(): Promise { + const hdrs = await headers(); + const host = hdrs.get("x-forwarded-host") || hdrs.get("host"); + const protocol = hdrs.get("x-forwarded-proto") || "https"; + const fallbackBase = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const base = host ? `${protocol}://${host}` : fallbackBase; + + const response = await fetch(`${base}/api/blog?sort=recent`, { + next: { revalidate: 300 }, + }); + + if (!response.ok) { + return { posts: [], total: 0 }; + } + + return (await response.json()) as BlogListResponse; +} + +export default async function BlogPage() { + const { posts } = await fetchBlogPosts(); + const allTags = Array.from(new Set(posts.flatMap((post) => post.tags))).sort(); return (
@@ -28,8 +49,7 @@ export default function BlogPage() {

- {/* Blog Grid with filtering and search */} - +
); diff --git a/src/components/blog/blog-grid.tsx b/src/components/blog/blog-grid.tsx index 45e72340..55a1a78d 100644 --- a/src/components/blog/blog-grid.tsx +++ b/src/components/blog/blog-grid.tsx @@ -1,34 +1,32 @@ "use client"; -import { useState } from 'react'; -import Link from 'next/link'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import type { BlogPost } from '@/lib/data'; +import { useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import type { BlogPost } from "@/lib/blog"; interface BlogGridProps { posts: BlogPost[]; - categories: string[]; + tags: string[]; } -export function BlogGrid({ posts, categories }: BlogGridProps) { - const [selectedCategory, setSelectedCategory] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); +export function BlogGrid({ posts, tags }: BlogGridProps) { + const [selectedTag, setSelectedTag] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); - // Filter posts const filteredPosts = posts.filter((post) => { - // Category filter - if (selectedCategory && post.category !== selectedCategory) { + if (selectedTag && !post.tags.includes(selectedTag)) { return false; } - // Search filter if (searchQuery) { const query = searchQuery.toLowerCase(); return ( post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || + post.author.toLowerCase().includes(query) || post.tags.some((tag) => tag.toLowerCase().includes(query)) ); } @@ -36,31 +34,18 @@ export function BlogGrid({ posts, categories }: BlogGridProps) { return true; }); - const getCategoryColor = (category: string) => { - const colors: Record = { - Engineering: 'bg-cyan/10 text-cyan border-cyan/30', - Product: 'bg-purple/10 text-purple border-purple/30', - Community: 'bg-green-500/10 text-green-500 border-green-500/30', - Tutorials: 'bg-blue-500/10 text-blue-500 border-blue-500/30', - 'Case Studies': 'bg-orange-500/10 text-orange-500 border-orange-500/30', - }; - return colors[category] || 'bg-white/5 text-white/60 border-white/10'; - }; - const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", }); }; return (
- {/* Filters */}
- {/* Search */}
- {/* Category Filter */}
- {categories.map((category) => ( + {tags.map((tag) => ( ))}
- {/* Results count */}
{filteredPosts.length === posts.length - ? `${posts.length} ${posts.length === 1 ? 'post' : 'posts'}` - : `${filteredPosts.length} of ${posts.length} ${posts.length === 1 ? 'post' : 'posts'}`} + ? `${posts.length} ${posts.length === 1 ? "post" : "posts"}` + : `${filteredPosts.length} of ${posts.length} ${posts.length === 1 ? "post" : "posts"}`}
- {/* Blog Grid */} {filteredPosts.length === 0 ? (

No posts found

@@ -126,35 +108,36 @@ export function BlogGrid({ posts, categories }: BlogGridProps) { href={`/blog/${post.slug}`} className="group bg-card/50 border border-white/5 rounded-lg p-6 hover:border-cyan/20 transition-all" > - {/* Category Tag */} -
- - {post.category} - +
+ {post.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))}
- {/* Title */}

{post.title}

- {/* Excerpt */}

{post.excerpt}

- {/* Author with avatar placeholder */}
- {post.author.name.charAt(0)} + {post.author.charAt(0)}
-
{post.author.name}
-
{post.author.role}
+
{post.author}
+
Author
- {/* Meta Information */}
{formatDate(post.publishedAt)} {post.readingTime} diff --git a/src/lib/blog.ts b/src/lib/blog.ts new file mode 100644 index 00000000..06298684 --- /dev/null +++ b/src/lib/blog.ts @@ -0,0 +1,90 @@ +import { promises as fs } from "fs"; +import path from "path"; + +export type BlogPost = { + title: string; + slug: string; + content: string; // markdown + author: string; + tags: string[]; + publishedAt: string; + updatedAt: string; + excerpt: string; + readingTime: string; +}; + +export type BlogListResponse = { + posts: BlogPost[]; + total: number; +}; + +const BLOG_POSTS_PATH = path.join(process.cwd(), "data", "blog-posts.json"); + +export async function readBlogPosts(): Promise { + try { + const raw = await fs.readFile(BLOG_POSTS_PATH, "utf-8"); + const parsed = JSON.parse(raw) as BlogPost[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export async function writeBlogPosts(posts: BlogPost[]): Promise { + await fs.mkdir(path.dirname(BLOG_POSTS_PATH), { recursive: true }); + await fs.writeFile(BLOG_POSTS_PATH, JSON.stringify(posts, null, 2)); +} + +function stripMarkdown(md: string): string { + return md + .replace(/```[\s\S]*?```/g, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .replace(/[*_>#-]/g, "") + .replace(/\n+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function buildExcerpt(markdown: string, maxLength = 160): string { + const text = stripMarkdown(markdown); + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 1).trim()}…`; +} + +export function estimateReadingTime(markdown: string): string { + const wordCount = stripMarkdown(markdown).split(/\s+/).filter(Boolean).length; + const minutes = Math.max(1, Math.ceil(wordCount / 200)); + return `${minutes} min read`; +} + +export function filterAndSortPosts( + posts: BlogPost[], + opts: { search?: string; tag?: string; sort?: string } +): BlogPost[] { + const search = opts.search?.trim().toLowerCase(); + const tag = opts.tag?.trim().toLowerCase(); + + let filtered = posts; + + if (search) { + filtered = filtered.filter((post) => { + const haystack = [post.title, post.excerpt, post.content, post.author, post.tags.join(" ")] + .join(" ") + .toLowerCase(); + return haystack.includes(search); + }); + } + + if (tag) { + filtered = filtered.filter((post) => post.tags.some((t) => t.toLowerCase() === tag)); + } + + if (opts.sort === "recent") { + filtered = [...filtered].sort( + (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() + ); + } + + return filtered; +}