NextReady includes a complete blog system that allows you to create, manage, and publish blog posts with support for internationalization.
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.
NextReady uses MongoDB to store blog posts. The main data model for the blog system is:
// 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
NextReady includes API routes for managing blog posts.
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
// Example: Get all blog posts
const response = await fetch('/api/posts')
const posts = await response.json()
// 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'
})
})
// 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
})
})
// Example: Delete a blog post
const response = await fetch('/api/posts/' + postId, {
method: 'DELETE'
})
// Example: Get a post by slug
const response = await fetch('/api/posts/slug/' + slug)
const post = await response.json()
NextReady includes an admin interface for creating and editing blog posts.
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>
)
}
NextReady provides components and pages for displaying blog posts.
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>
)
}
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>
)
}
The blog system in NextReady is fully integrated with the internationalization system, allowing you to create blog posts in multiple languages.
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
}
}
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>
)
}
NextReady allows you to customize various aspects of the blog system.
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>
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} />
}
Now that you understand how the blog system works in NextReady, you can: