Technology

How to Build a Blog with Next.js 15 and MongoDB

Team LinkAssist
LinkAssist Team
Author
December 10, 2025
12 min read
61 views
Share:
Img

Complete Blog Post: How to Build a Blog with Next.js 15 and MongoDB


Introduction

In this comprehensive tutorial, we'll build a complete, production-ready blog system using Next.js 15, MongoDB, and modern best practices. By the end of this guide, you'll have a fully functional blog with a powerful admin panel, SEO optimization, and cloud storage integration.

What you'll build:

  • A lightning-fast blog with Next.js 15 App Router
  • MongoDB database for content management
  • Rich text editor for creating posts
  • SEO-optimized pages with meta tags and schema markup
  • Cloudflare R2 for media storage and CDN
  • Secure authentication system
  • Responsive design with Tailwind CSS

This tutorial is perfect for developers who want to create a professional blog platform from scratch, whether for personal use, clients, or as a SaaS product.


Prerequisites

Before we begin, make sure you have:

  • Node.js 18+ installed on your machine
  • Basic Next.js knowledge (routes, components, API routes)
  • MongoDB account (we'll use MongoDB Atlas - free tier)
  • Cloudflare account (for R2 storage - free tier)
  • Code editor (VS Code recommended)
  • Terminal/Command line familiarity
  • Git installed (optional but recommended)

Helpful (but not required):

  • Understanding of React hooks
  • Experience with Tailwind CSS
  • Basic knowledge of databases
  • Familiarity with REST APIs


Step 1: Setup Next.js Project

Let's create a new Next.js 15 project with all the necessary configurations.

1.1 Create Next.js App

Open your terminal and run:

npx create-next-app@latest blog-cms

When prompted, select these options:

✔ Would you like to use TypeScript? … No
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? … Yes
✔ Would you like to customize the default import alias (@/*)? … No

1.2 Navigate to Project

cd blog-cms

1.3 Install Dependencies

Install all required packages:

# Database & Auth
npm install mongoose next-auth@beta bcryptjs

# AWS SDK for Cloudflare R2 (S3-compatible)
npm install @aws-sdk/client-s3

# Rich Text Editor
npm install lexical @lexical/react @lexical/rich-text @lexical/utils

# Form Handling
npm install react-hook-form zod @hookform/resolvers

# UI Components
npm install lucide-react date-fns

# Image Optimization
npm install sharp

# Utilities
npm install slugify

1.4 Install Shadcn UI

Initialize Shadcn UI for beautiful components:

npx shadcn-ui@latest init

Select these options:

✔ Would you like to use TypeScript? … no
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? › app/globals.css
✔ Would you like to use CSS variables for colors? › yes
✔ Where is your tailwind.config.js located? › tailwind.config.js
✔ Configure the import alias for components? › @/components
✔ Configure the import alias for utils? › @/lib/utils
✔ Are you using React Server Components? › yes

Install essential Shadcn components:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add select
npx shadcn-ui@latest add label
npx shadcn-ui@latest add card
npx shadcn-ui@latest add table
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add toast

1.5 Project Structure

Your project structure should look like this:

blog-cms/
├── app/
│ ├── (public)/ # Public routes
│ │ ├── page.jsx # Home page
│ │ └── blog/
│ │ ├── [slug]/
│ │ │ └── page.jsx
│ │ └── page.jsx
│ ├── admin/ # Admin panel
│ │ ├── layout.jsx
│ │ ├── dashboard/
│ │ ├── posts/
│ │ ├── media/
│ │ └── settings/
│ ├── api/ # API routes
│ │ ├── auth/
│ │ ├── posts/
│ │ ├── media/
│ │ └── settings/
│ ├── layout.jsx # Root layout
│ └── globals.css
├── components/
│ ├── admin/
│ ├── blog/
│ └── ui/ # Shadcn components
├── lib/
│ ├── db.js # MongoDB connection
│ ├── models/ # Mongoose models
│ ├── utils/
│ └── r2.js # Cloudflare R2 helper
├── middleware.js # Auth & redirects
├── .env.local # Environment variables
├── package.json
└── next.config.js


Step 2: Configure Environment Variables

2.1 Create .env.local File

In your project root, create .env.local:

touch .env.local

2.2 Add Environment Variables

Open .env.local and add:

# MongoDB Connection
MONGODB_URI=your-mongodb-uri-here

# NextAuth Configuration
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=http://localhost:3000

# Cloudflare R2 Storage
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_R2_ACCESS_KEY_ID=your-access-key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-secret-key
CLOUDFLARE_R2_BUCKET_NAME=your-bucket-name
CLOUDFLARE_R2_PUBLIC_URL=https://your-r2-url.com

# Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_SITE_NAME=My Blog

2.3 Generate NextAuth Secret

Generate a secure secret:

openssl rand -base64 32

Copy the output and paste it as NEXTAUTH_SECRET.

2.4 Add .env.local to .gitignore

Make sure .env.local is in your .gitignore:

# .gitignore
.env.local
.env*.local


Step 3: Setup MongoDB Database

3.1 Create MongoDB Atlas Account

  1. Go to https://www.mongodb.com/cloud/atlas
  2. Sign up for free account
  3. Create a new cluster (M0 Free tier)
  4. Choose your closest region

3.2 Create Database User

  1. Go to Database Access
  2. Click Add New Database User
  3. Choose Password authentication
  4. Username: blogadmin
  5. Password: Generate strong password
  6. Database User Privileges: Read and write to any database
  7. Click Add User

3.3 Whitelist IP Address

  1. Go to Network Access
  2. Click Add IP Address
  3. Click Allow Access from Anywhere (for development)
  4. Or add your specific IP
  5. Click Confirm

3.4 Get Connection String

  1. Go to DatabaseConnect
  2. Choose Connect your application
  3. Driver: Node.js, Version: 5.5 or later
  4. Copy the connection string:

mongodb+srv://blogadmin:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority

  1. Replace <password> with your actual password
  2. Add database name after .net/:

mongodb+srv://blogadmin:yourpassword@cluster0.xxxxx.mongodb.net/blog-cms?retryWrites=true&w=majority

  1. Paste this into your .env.local as MONGODB_URI

3.5 Create Database Connection File

Create lib/db.js:

// lib/db.js
import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}

let cached = global.mongoose;

if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}

async function dbConnect() {
if (cached.conn) {
return cached.conn;
}

if (!cached.promise) {
const opts = {
bufferCommands: false,
};

cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
console.log('✅ MongoDB connected successfully');
return mongoose;
});
}

try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}

return cached.conn;
}

export default dbConnect;


Step 4: Create Database Models

4.1 Create User Model

Create lib/models/User.js:

// lib/models/User.js
import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema({
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
},
password: {
type: String,
required: true,
},
role: {
type: String,
enum: ['admin', 'editor'],
default: 'editor',
},
createdAt: {
type: Date,
default: Date.now,
},
});

export default mongoose.models.User || mongoose.model('User', UserSchema);

4.2 Create Post Model

Create lib/models/Post.js:

// lib/models/Post.js
import mongoose from 'mongoose';

const PostSchema = new mongoose.Schema({
// Basic Info
title: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
unique: true,
},
excerpt: String,
content: {
type: String,
required: true,
},

// SEO Meta
metaTitle: String,
metaDescription: String,
metaKeywords: [String],
canonicalUrl: String,

// Open Graph Tags
ogTags: {
title: String,
description: String,
image: String,
url: String,
type: String,
siteName: String,
locale: String,
},

// Twitter Cards
twitterCards: {
card: String,
title: String,
description: String,
image: String,
site: String,
creator: String,
},

// Schema Markup
schema: mongoose.Schema.Types.Mixed,
pageType: {
type: String,
default: 'BlogPosting',
},

// Media
featuredImage: {
url: String,
alt: String,
title: String,
caption: String,
description: String,
},

// Taxonomy
category: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category',
},
tags: [String],

// Settings
language: {
type: String,
default: 'en',
},
featured: {
type: Boolean,
default: false,
},
nofollow: {
type: Boolean,
default: true,
},

// Publishing
status: {
type: String,
enum: ['draft', 'published'],
default: 'draft',
},
publishedAt: Date,

// Meta
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
views: {
type: Number,
default: 0,
},
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
});

// Update updatedAt on save
PostSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});

// Index for better query performance
PostSchema.index({ slug: 1 });
PostSchema.index({ status: 1, publishedAt: -1 });

export default mongoose.models.Post || mongoose.model('Post', PostSchema);

4.3 Create Category Model

Create lib/models/Category.js:

// lib/models/Category.js
import mongoose from 'mongoose';

const CategorySchema = new mongoose.Schema({
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
unique: true,
},
description: String,
metaTitle: String,
metaDescription: String,
createdAt: {
type: Date,
default: Date.now,
},
});

export default mongoose.models.Category || mongoose.model('Category', CategorySchema);

4.4 Create Media Model

Create lib/models/Media.js:

// lib/models/Media.js
import mongoose from 'mongoose';

const MediaSchema = new mongoose.Schema({
filename: {
type: String,
required: true,
},
url: {
type: String,
required: true,
},
customUrl: String,
r2Key: String, // Key in R2 storage

// Metadata
alt: String,
title: String,
caption: String,
description: String,

// File Info
mimeType: String,
size: Number,
width: Number,
height: Number,

uploadedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
createdAt: {
type: Date,
default: Date.now,
},
});

export default mongoose.models.Media || mongoose.model('Media', MediaSchema);

4.5 Create Settings Model

Create lib/models/Settings.js:

// lib/models/Settings.js
import mongoose from 'mongoose';

const SettingsSchema = new mongoose.Schema({
// Branding
favicon: String,
logo: String,
siteName: {
type: String,
default: 'My Blog',
},

// Title Format
titleSeparator: {
type: String,
default: '|',
},
titleFormat: {
type: String,
default: 'PageTitle | SiteName',
},

// Code Injection
analyticsCode: String,
searchConsoleCode: String,
customHeadCode: String,
customBodyCode: String,

// SEO Files
robotsTxt: {
type: String,
default: `User-agent: *
Allow: /
Sitemap: https://yourdomain.com/sitemap.xml`,
},

// Default Meta
defaultMetaDescription: String,
defaultOgImage: String,

updatedAt: {
type: Date,
default: Date.now,
},
});

export default mongoose.models.Settings || mongoose.model('Settings', SettingsSchema);

4.6 Create Redirect Model

Create lib/models/Redirect.js:

// lib/models/Redirect.js
import mongoose from 'mongoose';

const RedirectSchema = new mongoose.Schema({
from: {
type: String,
required: true,
unique: true,
},
to: {
type: String,
required: true,
},
type: {
type: Number,
enum: [301, 302],
default: 301,
},
active: {
type: Boolean,
default: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});

export default mongoose.models.Redirect || mongoose.model('Redirect', RedirectSchema);


Step 5: Setup Cloudflare R2 Storage

5.1 Create R2 Bucket

  1. Go to Cloudflare Dashboard
  2. Navigate to R2 from sidebar
  3. Click Create Bucket
  4. Bucket name: blog-media
  5. Location: Automatic
  6. Click Create Bucket

5.2 Enable Public Access

  1. Go to your bucket settings
  2. Click Settings
  3. Find Public Access section
  4. Click Allow Access
  5. Note your R2.dev URL: https://pub-xxxxx.r2.dev

5.3 Create API Token

  1. Click Manage R2 API Tokens
  2. Click Create API Token
  3. Token name: blog-storage
  4. Permissions: Admin Read & Write
  5. Click Create API Token
  6. SAVE both Access Key ID and Secret Access Key

5.4 Create R2 Helper

Create lib/r2.js:

// lib/r2.js
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import sharp from 'sharp';

const r2Client = new S3Client({
region: "auto",
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
},
});

// Upload file to R2
export async function uploadToR2(file, folder = 'uploads') {
const buffer = await file.arrayBuffer();

// Generate unique filename
const timestamp = Date.now();
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '-');
const filename = `${folder}/${timestamp}-${sanitizedName}`;

// Optimize image if it's an image
let finalBuffer = Buffer.from(buffer);
let width, height;

if (file.type.startsWith('image/')) {
const optimized = await sharp(Buffer.from(buffer))
.resize(2000, 2000, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer({ resolveWithObject: true });

finalBuffer = optimized.data;
width = optimized.info.width;
height = optimized.info.height;
}

// Upload to R2
await r2Client.send(new PutObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME,
Key: filename,
Body: finalBuffer,
ContentType: file.type,
CacheControl: 'public, max-age=31536000',
}));

return {
url: `${process.env.CLOUDFLARE_R2_PUBLIC_URL}/${filename}`,
key: filename,
size: finalBuffer.length,
mimeType: file.type,
width,
height,
};
}

// Delete from R2
export async function deleteFromR2(key) {
await r2Client.send(new DeleteObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME,
Key: key,
}));
}


Step 6: Setup Authentication

6.1 Create Seed Script for Admin User

Create lib/seed.js:

// lib/seed.js
import bcrypt from 'bcryptjs';
import dbConnect from './db.js';
import User from './models/User.js';

async function seedAdminUser() {
await dbConnect();

const adminExists = await User.findOne({ email: 'admin@blog.com' });

if (!adminExists) {
const hashedPassword = await bcrypt.hash('admin123', 10);

await User.create({
name: 'Admin User',
email: 'admin@blog.com',
password: hashedPassword,
role: 'admin',
});

console.log('✅ Admin user created');
console.log('Email: admin@blog.com');
console.log('Password: admin123');
} else {
console.log('ℹ️ Admin user already exists');
}

process.exit(0);
}

seedAdminUser();

6.2 Add Seed Script to package.json

Open package.json and add:

{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"seed": "node lib/seed.js"
}
}

6.3 Run Seed Script

npm run seed

You should see:

✅ MongoDB connected successfully
✅ Admin user created
Email: admin@blog.com
Password: admin123

6.4 Create NextAuth Configuration

Create app/api/auth/[...nextauth]/route.js:

// app/api/auth/[...nextauth]/route.js
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import dbConnect from "@/lib/db";
import User from "@/lib/models/User";

export const authOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
await dbConnect();

const user = await User.findOne({ email: credentials.email });

if (!user) {
throw new Error("No user found with this email");
}

const isValid = await bcrypt.compare(credentials.password, user.password);

if (!isValid) {
throw new Error("Invalid password");
}

return {
id: user._id.toString(),
email: user.email,
name: user.name,
role: user.role,
};
}
})
],
session: {
strategy: "jwt",
},
pages: {
signIn: "/admin/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.role = token.role;
session.user.id = token.id;
}
return session;
}
},
secret: process.env.NEXTAUTH_SECRET,
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

6.5 Create Middleware for Protection

Create middleware.js in project root:

// middleware.js
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";

export async function middleware(request) {
const path = request.nextUrl.pathname;

// Protect admin routes
if (path.startsWith('/admin') && !path.startsWith('/admin/login')) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});

if (!token) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}

return NextResponse.next();
}

export const config = {
matcher: ['/admin/:path*']
};


Step 7: Create Admin Login Page

7.1 Create Login Page

Create app/admin/login/page.jsx:

// app/admin/login/page.jsx
"use client";

import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);

try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});

if (result.error) {
setError(result.error);
} else {
router.push("/admin/dashboard");
router.refresh();
}
} catch (error) {
setError("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin Login</CardTitle>
<CardDescription>Enter your credentials to access the admin panel</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}

<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@blog.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>

<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>

<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}


Step 8: Test Your Setup

8.1 Start Development Server

npm run dev

8.2 Test Database Connection

The console should show:

✅ MongoDB connected successfully

8.3 Test Login

  1. Visit: http://localhost:3000/admin/login
  2. Enter:
    • Email: admin@blog.com
    • Password: admin123
  3. Click Sign In
  4. You should be redirected to /admin/dashboard


Step 9: Next Steps

Congratulations! You've successfully set up the foundation for your blog CMS. In the next parts of this series, we'll cover:

Part 2: Building the Admin Dashboard

  • Create dashboard layout with sidebar
  • Build post listing page
  • Implement post statistics

Part 3: Rich Text Editor Integration

  • Integrate Lexical editor
  • Add formatting toolbar
  • Implement image uploads

Part 4: SEO Optimization

  • Meta tags management
  • Open Graph implementation
  • Schema markup builder

Part 5: Blog Frontend

  • Create blog listing page
  • Build single post page
  • Implement pagination

Part 6: Deployment

  • Deploy to Vercel
  • Configure production environment
  • Set up custom domain


Troubleshooting

MongoDB Connection Issues

If you get connection errors:

# Check your connection string format
mongodb+srv://username:password@cluster.mongodb.net/database-name?retryWrites=true&w=majority

# Make sure:
# 1. Password has no special characters (or URL encode them)
# 2. IP is whitelisted
# 3. Database user has correct permissions

NextAuth Errors

If authentication doesn't work:

# Make sure NEXTAUTH_SECRET is set
openssl rand -base64 32

# Check NEXTAUTH_URL matches your domain
NEXTAUTH_URL=http://localhost:3000

R2 Upload Failures

If file uploads fail:

# Verify all R2 credentials are set
# Test connection with AWS CLI or Postman
# Check bucket permissions


Conclusion

You now have a solid foundation for your Next.js blog CMS with:

✅ Next.js 15 with App Router ✅ MongoDB database with models ✅ Cloudflare R2 for media storage ✅ Secure authentication system ✅ Admin panel structure ✅ Environment configuration

The next tutorial will cover building the admin dashboard and post management system.

Full source code: GitHub Repository

Enjoyed this article? Share it!