NextReady

Blog System

NextReady includes a complete blog system that allows you to create, manage, and publish blog posts with support for internationalization.

Overview

The blog system in NextReady allows you to create and manage blog posts directly from your application. It supports rich text content, featured images, categories, and is fully integrated with the internationalization system.

Key Features

  • MongoDB-based blog storage
  • Admin interface for post management
  • Multilingual support with next-intl
  • SEO-friendly URLs and metadata
  • Rich text editor for content creation
  • Categories and tags for post organization
  • Featured images and thumbnails

Data Model

NextReady uses MongoDB to store blog posts. The main data model for the blog system is:

Post Model


// src/models/Post.ts
import mongoose from "mongoose"

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
    trim: true,
  },
  slug: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
  },
  content: {
    type: String,
    required: true,
  },
  excerpt: {
    type: String,
    required: true,
  },
  coverImage: {
    type: String,
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
    required: true,
  },
  published: {
    type: Boolean,
    default: false,
  },
  publishedAt: {
    type: Date,
  },
  category: {
    type: String,
    default: "general",
  },
  tags: [String],
  locale: {
    type: String,
    enum: ["en", "fr", "es", "de"],
    default: "en",
  },
  translations: [
    {
      locale: {
        type: String,
        enum: ["en", "fr", "es", "de"],
      },
      slug: String,
      postId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: "Post",
      },
    },
  ],
  createdAt: {
    type: Date,
    default: Date.now,
  },
  updatedAt: {
    type: Date,
    default: Date.now,
  },
})

// Add indexes
postSchema.index({ slug: 1 })
postSchema.index({ author: 1 })
postSchema.index({ category: 1 })
postSchema.index({ tags: 1 })
postSchema.index({ locale: 1 })
postSchema.index({ published: 1, publishedAt: -1 })

const Post = mongoose.models.Post || mongoose.model("Post", postSchema)

export default Post

Blog API

NextReady includes API routes for managing blog posts.

Blog API Routes

The following blog API routes are available:

  • /api/posts - Get all posts or create a new post
  • /api/posts/[id] - Get, update, or delete a specific post
  • /api/posts/slug/[slug] - Get a post by slug

Get All Posts


// Example: Get all blog posts
const response = await fetch('/api/posts')
const posts = await response.json()

Create a New Post


// Example: Create a new blog post
const response = await fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    title: 'My New Blog Post',
    content: 'This is the content of my blog post...',
    excerpt: 'A short excerpt for the blog post',
    slug: 'my-new-blog-post',
    coverImage: '/images/blog/my-image.jpg',
    category: 'technology',
    tags: ['nextjs', 'react', 'tutorial'],
    published: true,
    locale: 'en'
  })
})

Update a Post


// Example: Update a blog post
const response = await fetch('/api/posts/' + postId, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    title: 'Updated Title',
    content: 'Updated content...'
    // Include other fields you want to update
  })
})

Delete a Post


// Example: Delete a blog post
const response = await fetch('/api/posts/' + postId, {
  method: 'DELETE'
})

Get Post by Slug


// Example: Get a post by slug
const response = await fetch('/api/posts/slug/' + slug)
const post = await response.json()

Creating and Editing Posts

NextReady includes an admin interface for creating and editing blog posts.

Post Editor

The post editor allows you to create and edit blog posts with a rich text editor:


// Client component
'use client'

import { useState } from "react"
import { useRouter } from "next/navigation"
import dynamic from "next/dynamic"
import { useSession } from "next-auth/react"

// Import rich text editor dynamically to avoid SSR issues
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  ssr: false,
})

export default function PostEditor({ post }: { post?: any }) {
  const { data: session } = useSession()
  const router = useRouter()
  const [title, setTitle] = useState(post?.title || "")
  const [slug, setSlug] = useState(post?.slug || "")
  const [content, setContent] = useState(post?.content || "")
  const [excerpt, setExcerpt] = useState(post?.excerpt || "")
  const [coverImage, setCoverImage] = useState(post?.coverImage || "")
  const [category, setCategory] = useState(post?.category || "general")
  const [tags, setTags] = useState(post?.tags?.join(", ") || "")
  const [published, setPublished] = useState(post?.published || false)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState("")
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    setLoading(true)
    setError("")
    
    try {
      const formData = {
        title,
        slug,
        content,
        excerpt,
        coverImage,
        category,
        tags: tags.split(",").map(tag => tag.trim()).filter(Boolean),
        published,
      }
      
      const url = post ? '/api/posts/' + post._id : '/api/posts'
      const method = post ? 'PUT' : 'POST'
      
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData),
      })
      
      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.message || "Failed to save post")
      }
      
      const savedPost = await response.json()
      
      // Redirect to the post list or the post itself
      router.push('/admin/blog')
      router.refresh()
    } catch (error) {
      setError(error.message)
    } finally {
      setLoading(false)
    }
  }
  
  // Generate slug from title
  const generateSlug = () => {
    const slug = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
    setSlug(slug)
  }
  
  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {error && (
        <div className="p-4 bg-red-50 text-red-700 rounded-lg">
          {error}
        </div>
      )}
      
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          Title
        </label>
        <input
          type="text"
          id="title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          onBlur={() => !post && generateSlug()}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>
      
      <div>
        <label htmlFor="slug" className="block text-sm font-medium">
          Slug
        </label>
        <div className="flex mt-1">
          <input
            type="text"
            id="slug"
            value={slug}
            onChange={(e) => setSlug(e.target.value)}
            required
            className="block w-full rounded-md border-gray-300 shadow-sm"
          />
          <button
            type="button"
            onClick={generateSlug}
            className="ml-2 px-3 py-2 bg-gray-200 rounded-md"
          >
            Generate
          </button>
        </div>
      </div>
      
      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          Content
        </label>
        <RichTextEditor
          value={content}
          onChange={setContent}
          className="mt-1"
        />
      </div>
      
      {/* Other form fields... */}
      
      <div className="flex justify-end">
        <button
          type="button"
          onClick={() => router.back()}
          className="px-4 py-2 border rounded-md mr-2"
        >
          Cancel
        </button>
        <button
          type="submit"
          disabled={loading}
          className="px-4 py-2 bg-blue-600 text-white rounded-md"
        >
          {loading ? "Saving..." : post ? "Update Post" : "Create Post"}
        </button>
      </div>
    </form>
  )
}

Displaying Posts

NextReady provides components and pages for displaying blog posts.

Blog List Page

The blog list page displays a list of blog posts:


// src/app/[locale]/blog/page.tsx
import { Metadata } from "next"
import Link from "next/link"
import Image from "next/image"
import { getPosts } from "@/lib/blog"

export const metadata: Metadata = {
  title: "Blog",
  description: "Latest news and articles",
}

export default async function BlogPage({
  params: { locale },
}: {
  params: { locale: string }
}) {
  const posts = await getPosts(locale)
  
  return (
    <div className="container mx-auto py-12">
      <h1 className="text-3xl font-bold mb-8">Blog</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {posts.map((post) => (
          <article key={post._id} className="border rounded-lg overflow-hidden">
            {post.coverImage && (
              <div className="relative h-48">
                <Image
                  src={post.coverImage}
                  alt={post.title}
                  fill
                  className="object-cover"
                />
              </div>
            )}
            <div className="p-4">
              <h2 className="text-xl font-semibold mb-2">
                <Link href={'/blog/' + post.slug}>{post.title}</Link>
              </h2>
              <p className="text-gray-600 mb-4">{post.excerpt}</p>
              <div className="flex justify-between items-center">
                <span className="text-sm text-gray-500">
                  {new Date(post.publishedAt).toLocaleDateString()}
                </span>
                <Link
                  href={'/blog/' + post.slug}
                  className="text-blue-600 hover:underline"
                >
                  Read more
                </Link>
              </div>
            </div>
          </article>
        ))}
      </div>
    </div>
  )
}

Blog Post Page

The blog post page displays a single blog post:


// src/app/[locale]/blog/[slug]/page.tsx
import { Metadata } from "next"
import Image from "next/image"
import { notFound } from "next/navigation"
import { getPost } from "@/lib/blog"

export async function generateMetadata({
  params,
}: {
  params: { locale: string; slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.locale, params.slug)
  
  if (!post) {
    return {
      title: "Post Not Found",
    }
  }
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: post.coverImage ? [post.coverImage] : [],
    },
  }
}

async function getPost(locale: string, slug: string) {
  try {
    const res = await fetch(
      process.env.NEXT_PUBLIC_APP_URL + '/api/posts/slug/' + slug + '?locale=' + locale,
      { next: { revalidate: 60 } }
    )
    
    if (!res.ok) {
      return null
    }
    
    return res.json()
  } catch (error) {
    console.error("Error fetching post:", error)
    return null
  }
}

export default async function BlogPostPage({
  params,
}: {
  params: { locale: string; slug: string }
}) {
  const post = await getPost(params.locale, params.slug)
  
  if (!post) {
    notFound()
  }
  
  return (
    <div className="container mx-auto py-12">
      <article>
        <header className="mb-8">
          <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
          <div className="flex items-center text-gray-600 mb-4">
            <span>
              {new Date(post.publishedAt).toLocaleDateString()}
            </span>
            <span className="mx-2">•</span>
            <span>{post.category}</span>
          </div>
          {post.coverImage && (
            <div className="relative h-96 mb-8">
              <Image
                src={post.coverImage}
                alt={post.title}
                fill
                className="object-cover rounded-lg"
              />
            </div>
          )}
        </header>
        
        <div className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: post.content }}
        />
        
        {post.tags.length > 0 && (
          <div className="mt-8 pt-4 border-t">
            <h3 className="text-lg font-semibold mb-2">Tags</h3>
            <div className="flex flex-wrap gap-2">
              {post.tags.map((tag) => (
                <span
                  key={tag}
                  className="px-3 py-1 bg-gray-100 rounded-full text-sm"
                >
                  {tag}
                </span>
              ))}
            </div>
          </div>
        )}
      </article>
    </div>
  )
}

Internationalization

The blog system in NextReady is fully integrated with the internationalization system, allowing you to create blog posts in multiple languages.

Creating Multilingual Posts

You can create posts in different languages and link them together as translations:


// Example: Link posts as translations
async function linkPostTranslations(postId, translations) {
  try {
    const response = await fetch('/api/posts/' + postId + '/translations', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ translations }),
    })
    
    if (!response.ok) {
      throw new Error('Failed to link translations')
    }
    
    return await response.json()
  } catch (error) {
    console.error('Error linking translations:', error)
    throw error
  }
}

Language Switcher for Blog

The blog includes a language switcher that allows users to switch between available translations:


// Client component
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export default function BlogLanguageSwitcher({ translations }) {
  const pathname = usePathname()
  
  // Extract current locale from pathname
  const currentLocale = pathname.split('/')[1]
  
  return (
    <div className="flex space-x-2 my-4">
      <span className="text-gray-600">Available in:</span>
      {translations.map((translation) => (
        <Link
          key={translation.locale}
          href={'/' + translation.locale + '/blog/' + translation.slug}
          className={
            translation.locale === currentLocale
              ? 'font-bold text-blue-600'
              : 'text-blue-600 hover:underline'
          }
        >
          {translation.locale.toUpperCase()}
        </Link>
      ))}
    </div>
  )
}

Customizing the Blog

NextReady allows you to customize various aspects of the blog system.

Custom Categories

You can define custom categories for your blog posts:


// Example: Define custom categories
const BLOG_CATEGORIES = [
  { id: 'general', name: 'General' },
  { id: 'technology', name: 'Technology' },
  { id: 'business', name: 'Business' },
  { id: 'design', name: 'Design' },
  { id: 'marketing', name: 'Marketing' },
]

// Use in your component
<select
  id="category"
  value={category}
  onChange={(e) => setCategory(e.target.value)}
  className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
>
  {BLOG_CATEGORIES.map((cat) => (
    <option key={cat.id} value={cat.id}>
      {cat.name}
    </option>
  ))}
</select>

Custom Rich Text Editor

You can customize the rich text editor used for creating blog posts:


// Custom rich text editor component
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'

// Import the editor dynamically to avoid SSR issues
const ReactQuill = dynamic(() => import('react-quill'), { ssr: false })
import 'react-quill/dist/quill.snow.css'

export default function CustomRichTextEditor({ value, onChange }) {
  // Ensure we have access to the window object
  const [mounted, setMounted] = useState(false)
  
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return null
  }
  
  const modules = {
    toolbar: [
      [{ header: [1, 2, 3, 4, 5, 6, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      [{ color: [] }, { background: [] }],
      ['link', 'image', 'video'],
      ['clean'],
    ],
  }
  
  return <ReactQuill value={value} onChange={onChange} modules={modules} />
}

Next Steps

Now that you understand how the blog system works in NextReady, you can:

  • Create your first blog post
  • Customize the blog appearance to match your brand
  • Add additional features like comments or social sharing
  • Implement SEO optimizations for your blog posts
  • Set up analytics to track blog performance