Next.js Integration Guide
Complete guide to integrating BlogNow CMS with Next.js App Router. Includes TypeScript, SSG/ISR, metadata, and production-ready examples.
💡 Quick Start: Copy this entire guide and paste it into Claude, Cursor, or any AI coding assistant to automatically set up BlogNow in your Next.js project.
BlogNow SDK Integration Prompt for Next.js App Router Projects
Context
You are an expert Next.js developer tasked with integrating BlogNow SDK into a Next.js project to create a complete blog system. Based on the analysis of a production implementation, create all necessary files and configurations for a fully functional blog with the following requirements:
Pre-Implementation Analysis
1. Check Existing Project Setup
Before implementing, analyze the existing project to understand:
State Management Library:
- Check if the project uses any state management (Redux, Zustand, Jotai, Valtio, etc.)
- Look for existing stores in
src/store
,src/stores
,lib/store
, or similar directories - Check
package.json
for state management dependencies - If no state management exists, ask the user: "No state management library detected. Would you like to proceed with Zustand, or do you prefer Redux Toolkit, Jotai, or another solution?"
Icon Library:
- Check if the project uses any icon library (lucide-react, react-icons, heroicons, etc.)
- Look for existing icon imports in components
- Check
package.json
for icon library dependencies - If no icon library exists, proceed with lucide-react
UI Component Library:
- Check if the project uses shadcn/ui, Chakra UI, Mantine, or other component libraries
- Look for existing component patterns and styling approaches
- Adapt the implementation to match the existing UI patterns
Project Setup Requirements
1. Package Installation
Install the required dependencies:
# Core BlogNow SDK (always required) npm install @blognow/sdk # State management (install only if not already present) npm install zustand # or user's preferred library # Icons (install only if not already present) npm install lucide-react # If using shadcn/ui components (only if already in project): npm install @radix-ui/react-* class-variance-authority clsx tailwind-merge
2. Environment Configuration
Create/update .env.local
:
NEXT_PUBLIC_BLOGNOW_API_KEY=your_public_api_key_here NEXT_PUBLIC_BLOGNOW_BASE_URL=https://api.blognow.tech
Security Note: BlogNow SDK only requires the public API key (NEXT_PUBLIC_BLOGNOW_API_KEY
) which is safe for client-side use. No private API key or server-side routes are needed.
3. Next.js Configuration
Update next.config.js/ts
to allow BlogNow CDN images:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "cdn.blognow.tech", pathname: "/**", }, ], }, };
Core Implementation Files
4. BlogNow Client Configuration (src/lib/blognow.ts
)
import { BlogNowClient } from "@blognow/sdk"; let blogClient: BlogNowClient | null = null; const apiKey = process.env.NEXT_PUBLIC_BLOGNOW_API_KEY; const baseUrl = process.env.NEXT_PUBLIC_BLOGNOW_BASE_URL; export const createBlogNowClient = async () => { if (!blogClient) { if (!apiKey) { throw new Error( "NEXT_PUBLIC_BLOGNOW_API_KEY is not configured in environment variables" ); } blogClient = new BlogNowClient({ apiKey, baseUrl }); } return blogClient; };
5. Blog Store (src/stores/blogStore.ts
)
Note: Adapt this implementation to match the project's existing state management library.
If using Zustand (example implementation):
import { create } from "zustand"; import { createBlogNowClient } from "@/lib/blognow"; import { PaginatedResponse, Post, PostStatus, GetPostsOptions, } from "@blognow/sdk"; interface BlogState { posts: Post[]; currentPost: Post | null; pagination: { total: number; page: number; size: number; pages: number; }; loading: boolean; error: string | null; searchQuery: string; selectedTag: string | null; // Actions fetchPosts: ( page?: number, size?: number, query?: string, sortBy?: "created_at" | "updated_at" | "published_at", sortOrder?: "asc" | "desc" ) => Promise<void>; fetchPostBySlug: (slug: string) => Promise<void>; filterByTag: (tag: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; clearCurrentPost: () => void; resetPagination: () => void; } const blogClient = await createBlogNowClient(); export const useBlogStore = create<BlogState>((set, get) => ({ posts: [], currentPost: null, loading: false, error: null, pagination: { page: 1, size: 10, total: 0, pages: 0 }, searchQuery: "", selectedTag: null, fetchPosts: async ( page = 1, size = 10, query = "", sortBy = "published_at", sortOrder = "desc" ) => { const { setLoading, setError } = get(); try { setLoading(true); setError(null); const result: PaginatedResponse<Post> = await blogClient.posts.getPublishedPosts({ page, size, query, sort_by: sortBy, sort_order: sortOrder, }); set((state) => ({ posts: page === 1 ? result.items : [...state.posts, ...result.items], pagination: { page: result.page || 1, size: result.size || 10, total: result.total || 0, pages: result.pages || 0, }, })); } catch (error) { setError( error instanceof Error ? error.message : "Failed to fetch posts" ); } finally { setLoading(false); } }, fetchPostBySlug: async (slug: string) => { const { setLoading, setError } = get(); try { setLoading(true); setError(null); const result: Post = await blogClient.posts.getPost(slug); set({ currentPost: result }); } catch (error) { setError(error instanceof Error ? error.message : "Failed to fetch post"); } finally { setLoading(false); } }, filterByTag: (tag: string | null) => set({ selectedTag: tag }), setLoading: (loading: boolean) => set({ loading }), setError: (error: string | null) => set({ error }), clearCurrentPost: () => set({ currentPost: null }), resetPagination: () => set({ pagination: { page: 1, size: 10, total: 0, pages: 0 }, }), }));
If using Redux Toolkit:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; import { createBlogNowClient } from "@/lib/blognow"; import { PaginatedResponse, Post } from "@blognow/sdk"; // Async thunks export const fetchPosts = createAsyncThunk( "blog/fetchPosts", async ({ page = 1, size = 10, query = "", }: { page?: number; size?: number; query?: string; }) => { const blogClient = await createBlogNowClient(); return await blogClient.posts.getPublishedPosts({ page, size, query, sort_by: "published_at", sort_order: "desc", }); } ); export const fetchPostBySlug = createAsyncThunk( "blog/fetchPostBySlug", async (slug: string) => { const blogClient = await createBlogNowClient(); return await blogClient.posts.getPost(slug); } ); // Slice const blogSlice = createSlice({ name: "blog", initialState: { posts: [] as Post[], currentPost: null as Post | null, pagination: { page: 1, size: 10, total: 0, pages: 0 }, loading: false, error: null as string | null, searchQuery: "", selectedTag: null as string | null, }, reducers: { filterByTag: (state, action) => { state.selectedTag = action.payload; }, setSearchQuery: (state, action) => { state.searchQuery = action.payload; }, clearCurrentPost: (state) => { state.currentPost = null; }, }, extraReducers: (builder) => { builder .addCase(fetchPosts.pending, (state) => { state.loading = true; state.error = null; }) .addCase(fetchPosts.fulfilled, (state, action) => { state.loading = false; const isFirstPage = action.meta.arg.page === 1; state.posts = isFirstPage ? action.payload.items : [...state.posts, ...action.payload.items]; state.pagination = { page: action.payload.page || 1, size: action.payload.size || 10, total: action.payload.total || 0, pages: action.payload.pages || 0, }; }) .addCase(fetchPosts.rejected, (state, action) => { state.loading = false; state.error = action.error.message || "Failed to fetch posts"; }) .addCase(fetchPostBySlug.fulfilled, (state, action) => { state.currentPost = action.payload; }); }, }); export const { filterByTag, setSearchQuery, clearCurrentPost } = blogSlice.actions; export default blogSlice.reducer;
6. Blog Cache Utility (src/lib/blog-cache.ts
)
import { Post } from "@blognow/sdk"; import { createBlogNowClient } from "@/lib/blognow"; const postCache = new Map<string, Post>(); let allPostsCached = false; export async function getCachedPosts(): Promise<Post[]> { if (allPostsCached) return Array.from(postCache.values()); try { const blogClient = await createBlogNowClient(); const posts = []; for await (const post of blogClient.posts.iteratePublishedPosts()) { postCache.set(post.slug, post); posts.push(post); } allPostsCached = true; return posts; } catch (error) { console.error("Error fetching posts for cache:", error); return []; } } export function getCachedPost(slug: string): Post | null { return postCache.get(slug) || null; } export async function getPost(slug: string): Promise<Post | null> { const cachedPost = getCachedPost(slug); if (cachedPost) return cachedPost; if (!allPostsCached) { await getCachedPosts(); const postAfterCache = getCachedPost(slug); if (postAfterCache) return postAfterCache; } try { const blogClient = await createBlogNowClient(); const post = await blogClient.posts.getPost(slug); if (post) postCache.set(post.slug, post); return post; } catch (error) { console.error("Error fetching post:", error); return null; } }
Blog Pages Implementation
7. Blog Layout (src/app/blog/layout.tsx
)
import { Metadata } from "next"; export const metadata: Metadata = { title: "Blog | Your Company Name", description: "Insights, updates, and stories from our team", openGraph: { title: "Blog | Your Company Name", description: "Insights, updates, and stories from our team", type: "website", siteName: "Your Company Name", }, twitter: { card: "summary_large_image", title: "Blog | Your Company Name", description: "Insights, updates, and stories from our team", }, alternates: { canonical: "https://yoursite.com/blog", }, }; export default function BlogLayout({ children, }: { children: React.ReactNode; }) { return children; }
8. Blog Index Page (src/app/blog/page.tsx
)
"use client"; import { useCallback, useEffect, useState } from "react"; import { useBlogStore } from "@/stores/blogStore"; import Link from "next/link"; import Image from "next/image"; // Icons - adapt based on the project's icon library import { Search, Clock, Tag, ChevronRight } from "lucide-react"; // if using lucide-react // import { FaSearch, FaClock, FaTag, FaChevronRight } from "react-icons/fa"; // if using react-icons // import { MagnifyingGlassIcon, ClockIcon, TagIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; // if using heroicons // Button component - adapt based on the project's component library import { Button } from "@/components/ui/button"; // if using shadcn/ui // Use native button or project's existing button component if no component library export default function BlogPage() { const { posts, loading, error, pagination, searchQuery, selectedTag, fetchPosts, filterByTag, } = useBlogStore(); const [searchInput, setSearchInput] = useState(""); const [filteredPosts, setFilteredPosts] = useState(posts); useEffect(() => { fetchPosts(); }, [fetchPosts]); useEffect(() => { setFilteredPosts(posts); }, [posts]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const inputTerm = searchInput.trim(); if (inputTerm) { setFilteredPosts( posts.filter((post) => post.title.toLowerCase().includes(inputTerm.toLowerCase()) ) ); } }; const handleTagFilter = (tag: string | null) => { filterByTag(tag); }; const loadMorePosts = () => { if (pagination.page < pagination.pages) { fetchPosts(pagination.page + 1); } }; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); }; return ( <div className="min-h-screen bg-gray-50"> {/* Header */} <div className="bg-white border-b"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="text-center"> <h1 className="text-4xl font-bold text-gray-900 mb-4">Our Blog</h1> <p className="text-xl text-gray-600 max-w-2xl mx-auto"> Insights, updates, and stories from our team </p> </div> </div> </div> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> {/* Search and Filters */} <div className="mb-8"> <form onSubmit={handleSearch} className="flex gap-4 mb-6"> <div className="flex-1 relative"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" /> <input type="text" placeholder="Search articles..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> </div> <Button variant="default">Search</Button> </form> </div> {/* Error State */} {error && ( <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> {error} </div> )} {/* Loading State */} {loading && posts.length === 0 ? ( <div className="flex justify-center items-center py-12"> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> </div> ) : ( <> {/* Posts Grid */} {filteredPosts.length === 0 ? ( <div className="text-center py-12"> <p className="text-gray-600 text-lg">No articles found.</p> </div> ) : ( <div className="grid gap-8 md:grid-cols-1 lg:grid-cols-2"> {filteredPosts.map((post) => ( <article key={post.id} className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden" > {/* Featured Image */} {post.og_image_url && ( <div className="relative"> <Image src={post.og_image_url} alt={post.title} height={1000} width={1000} className="object-cover hover:scale-105 transition-transform duration-300" /> </div> )} <div className="p-6"> {/* Meta Information */} <div className="flex items-center gap-4 text-sm text-gray-500 mb-3"> <span>{formatDate(post.published_at!)}</span> </div> {/* Title */} <h2 className="text-xl font-bold text-gray-900 mb-3 line-clamp-2"> {post.title} </h2> {/* Excerpt */} <p className="text-gray-600 mb-4 line-clamp-3"> {post.excerpt} </p> {/* Footer */} <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> {post.author?.avatar_url && ( <div className="relative h-8 w-8"> <Image src={post.author.avatar_url} alt={`${post.author.first_name} ${post.author.last_name}`} fill className="rounded-full object-cover" /> </div> )} <span className="text-sm font-medium text-gray-900"> {post.author?.first_name} {post.author?.last_name} </span> </div> <Link href={`/blog/${post.slug}`} className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-medium text-sm" > Read more <ChevronRight className="h-4 w-4" /> </Link> </div> </div> </article> ))} </div> )} {/* Load More Button */} {!selectedTag && pagination.page < pagination.pages && ( <div className="text-center mt-12"> <button onClick={loadMorePosts} disabled={loading} className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {loading ? "Loading..." : "Load More Articles"} </button> </div> )} </> )} </div> </div> ); }
9. Individual Blog Post Page (src/app/blog/[slug]/page.tsx
)
// Icons - adapt based on the project's icon library import { ArrowLeft, Calendar, User } from "lucide-react"; // if using lucide-react // import { FaArrowLeft, FaCalendar, FaUser } from "react-icons/fa"; // if using react-icons import Image from "next/image"; import Link from "next/link"; import { ShareButton } from "./share-button"; import { notFound } from "next/navigation"; import { Metadata } from "next"; import { getCachedPosts, getPost } from "@/lib/blog-cache"; interface BlogPostPageProps { params: Promise<{ slug: string }>; } // Generate metadata for SEO export async function generateMetadata({ params, }: BlogPostPageProps): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); if (!post) { return { title: "Post not found" }; } return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: post.og_image_url ? [post.og_image_url] : [], type: "article", publishedTime: post.published_at, authors: post.author ? [`${post.author.first_name} ${post.author.last_name}`] : [], }, twitter: { card: "summary_large_image", title: post.title, description: post.excerpt, images: post.og_image_url ? [post.og_image_url] : [], }, alternates: { canonical: `https://yoursite.com/blog/${post.slug}`, }, }; } // Generate static params for all published blog posts export async function generateStaticParams() { try { const posts = await getCachedPosts(); return posts.map((post) => ({ slug: post.slug })); } catch (error) { console.error("Error generating static params:", error); return []; } } // Enable ISR - pages will be regenerated at most once every hour export const revalidate = 3600; export default async function BlogPostPage({ params }: BlogPostPageProps) { const { slug } = await params; const currentPost = await getPost(slug); if (!currentPost) { notFound(); } const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); }; return ( <div className="min-h-screen flex flex-col justify-between bg-white"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Back Button */} <Link href="/blog" className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 mb-8" > <ArrowLeft className="h-4 w-4" /> Back to Blog </Link> <article> {/* Header */} <header className="mb-8"> <h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-6"> {currentPost.title} </h1> <div className="flex flex-wrap items-center gap-6 text-gray-600 mb-6"> <div className="flex items-center gap-2"> <Calendar className="h-4 w-4" /> <span>{formatDate(currentPost.published_at || "")}</span> </div> <div className="flex items-center gap-2"> <User className="h-4 w-4" /> <span> {currentPost.author?.first_name}{" "} {currentPost.author?.last_name} </span> </div> <ShareButton title={currentPost.title} excerpt={currentPost.excerpt} url={`/blog/${currentPost.slug}`} /> </div> </header> {/* Featured Image */} {currentPost.og_image_url && ( <div className="mb-8 relative rounded-lg"> <Image src={currentPost.og_image_url} alt={currentPost.title} height={1000} width={1000} className="object-cover" /> </div> )} {/* Content */} <div dangerouslySetInnerHTML={{ __html: currentPost.content }} className="blog-content flex flex-col gap-4 text-gray-800 text-lg leading-10 tracking-normal" /> </article> {/* Author Footer */} <footer className="mt-12 pt-8 border-t border-gray-200"> <div className="flex items-center justify-between"> <div className="flex items-center gap-4"> {currentPost.author?.avatar_url && ( <div className="relative h-12 w-12"> <Image src={currentPost.author.avatar_url} alt={`${currentPost.author.first_name} ${currentPost.author.last_name}`} fill className="rounded-full object-cover" /> </div> )} <div> <p className="font-semibold text-gray-900"> {currentPost.author?.first_name}{" "} {currentPost.author?.last_name} </p> <p className="text-gray-600 text-sm"> Published on {formatDate(currentPost.published_at || "")} </p> </div> </div> <ShareButton title={currentPost.title} excerpt={currentPost.excerpt} url={`/blog/${currentPost.slug}`} variant="button" /> </div> </footer> </div> {/* CTA Section */} <div className="bg-gray-100 py-12"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <h2 className="text-2xl font-bold text-gray-900 mb-4"> Want to read more? </h2> <p className="text-gray-600 mb-6"> Explore more articles on our blog </p> <Link href="/blog" className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors inline-block" > View All Articles </Link> </div> </div> </div> ); }
10. Styling for the blog conent, append to (src/app/global.css
)
div.blog-content { h1, h2, h3, h4, h5, h6 { @apply font-bold tracking-normal; } h1 { @apply text-3xl/12 md:text-5xl/14 lg:text-6xl/16; } h2 { @apply text-2xl/9 md:text-4xl/12; } h3 { @apply text-2xl md:text-3xl; } h4 { @apply text-xl md:text-2xl; } section { @apply py-16 md:py-24; } a { @apply underline text-blue-600 hover:text-blue-800 visited:text-purple-600; } }
11. Share Button Component (src/app/blog/[slug]/share-button.tsx
)
"use client"; // Icons - adapt based on the project's icon library import { Share2 } from "lucide-react"; // if using lucide-react // import { FaShare } from "react-icons/fa"; // if using react-icons interface ShareButtonProps { title: string; excerpt?: string; url: string; variant?: "link" | "button"; } export function ShareButton({ title, excerpt, url, variant = "link", }: ShareButtonProps) { const sharePost = async () => { const fullUrl = `${window.location.origin}${url}`; if (navigator.share) { try { await navigator.share({ title, text: excerpt, url: fullUrl, }); } catch (error) { console.log("Error sharing:", error); navigator.clipboard.writeText(fullUrl); } } else { navigator.clipboard.writeText(fullUrl); } }; if (variant === "button") { return ( <button onClick={sharePost} className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2" > <Share2 className="h-4 w-4" /> Share Article </button> ); } return ( <button onClick={sharePost} className="flex items-center gap-2 text-blue-600 hover:text-blue-800" > <Share2 className="h-4 w-4" /> <span>Share</span> </button> ); }
Implementation Notes
Key Features Implemented:
- Static Site Generation (SSG) with ISR for optimal performance
- SEO optimization with proper metadata and Open Graph tags
- Responsive design with Tailwind CSS/ user selected css lib
- State management using Zustand
- Image optimization with Next.js Image component
- Search functionality and filtering
- Progressive loading with pagination
- Social sharing capabilities
- Caching strategy for improved performance
- TypeScript support throughout
Customization Options:
- Update company name and URLs in metadata
- Modify styling and layout to match brand
- Add newsletter signup
- Include related posts suggestions
Security Considerations:
- BlogNow public API keys are safe for client-side use and designed for this purpose
- Sanitize HTML content if allowing user-generated content
This implementation provides a production-ready blog system that can be deployed immediately with minimal configuration changes.