Create a blog platform with server-rendered pages, a database, and auth. This project combines Next.js App Router, an ORM, and protected routes.

Requirements

  • Node.js 18+ and npm
  • Basic React and Next.js familiarity
  • A free Neon or Supabase PostgreSQL database

Features

  • Public blog listing and individual post pages
  • Markdown content rendered to HTML
  • Admin dashboard to create, edit, and delete posts
  • User authentication for admin routes
  • Responsive layout

Step 1: Scaffold the Project

  npx create-next-app@latest my-blog --typescript --tailwind --app --src-dir
cd my-blog
npm install @prisma/client next-auth bcryptjs react-markdown
npm install -D prisma
npx prisma init
  

Step 2: Database Schema

prisma/schema.prisma

  generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id       Int    @id @default(autoincrement())
  email    String @unique
  password String
  posts    Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  slug      String   @unique
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
}
  

Run migrations with npx prisma migrate dev --name init.

Step 3: Blog Listing Page

src/app/page.tsx

  import Link from 'next/link';
import { prisma } from '@/lib/prisma';

export default async function Home() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    select: { id: true, title: true, slug: true, createdAt: true },
  });

  return (
    <main className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">My Blog</h1>
      <ul className="space-y-4">
        {posts.map(post => (
          <li key={post.id}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
            <p className="text-gray-500 text-sm">{post.createdAt.toLocaleDateString()}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}
  

Step 4: Post Page

src/app/blog/[slug]/page.tsx — fetch by slug with prisma.post.findUnique, call notFound() if missing, and render content with <ReactMarkdown>.

  export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug, published: true },
    include: { author: { select: { email: true } } },
  });
  if (!post) notFound();
  return (
    <article className="max-w-2xl mx-auto p-8 prose">
      <h1>{post.title}</h1>
      <p className="text-gray-500">By {post.author.email}</p>
      <ReactMarkdown>{post.content}</ReactMarkdown>
    </article>
  );
}
  

Step 5: Admin API and Auth

src/app/api/posts/route.ts — protect writes with NextAuth:

  export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { title, content, slug } = await request.json();
  const post = await prisma.post.create({
    data: { title, content, slug, published: true, authorId: Number(session.user.id) },
  });
  return NextResponse.json(post, { status: 201 });
}
  

Configure NextAuth with a credentials provider in src/app/api/auth/[...nextauth]/route.ts. Hash passwords with bcryptjs on registration and protect /admin routes with getServerSession().

Step 6: Admin Dashboard

src/app/admin/page.tsx

  'use client';
import { useState } from 'react';

export default function AdminPage() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const slug = title.toLowerCase().replace(/\s+/g, '-');
    await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content, slug }),
    });
    setTitle(''); setContent('');
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-8 space-y-4">
      <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Post title" className="w-full border p-2 rounded" />
      <textarea value={content} onChange={e => setContent(e.target.value)} placeholder="Write in Markdown..." rows={12} className="w-full border p-2 rounded" />
      <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">Publish</button>
    </form>
  );
}
  

Extension Ideas

  • Draft mode — save unpublished posts and preview before publishing
  • Tags and categories — filter posts by topic
  • Comments — add a Comment model with moderation
  • SEO — dynamic metadata and Open Graph images per post
  • Image uploads — store cover images in Cloudinary or S3
  • RSS feed — generate /feed.xml for subscribers