Affordable and Professional Website Design Agency for Small Businesses and Startups

Building A Blogging Platform With Laravel Nova And Next.js

Building A Blogging Platform With Laravel Nova And Next.js
January 10, 2025
Written By Sumeet Shroff

Laravel, Next.js, Web Development

Building a modern blog platform requires a systematic approach combining Laravel 11's robust backend with Next.js 14's App Router framework. This solution delivers exceptional performance, SEO optimization, and a seamless user experience.

Architecture Overview

Backend (Laravel 11)

  • Admin panel powered by Laravel Nova
  • RESTful API endpoints
  • Authentication and authorization
  • Database management
  • Content management

Frontend (Next.js 14)

  • Server-side rendering
  • Static site generation
  • Dynamic routing
  • SEO optimization
  • Responsive design

Backend Implementation

Laravel Installation and Setup

composer create-project laravel/laravel:^11.0 blog-backend
cd blog-backend
composer require laravel/nova
php artisan nova:install

Blog Post Model

// app/Models/BlogPost.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class BlogPost extends Model
{
    protected $fillable = [
        'title',
        'slug',
        'content',
        'published_at'
    ];

    protected $casts = [
        'published_at' => 'datetime'
    ];
}

Nova Resource

// app/Nova/BlogPost.php
namespace App\Nova;

use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Fields\DateTime;

class BlogPost extends Resource
{
    public static $model = \App\Models\BlogPost::class;

    public function fields(Request $request)
    {
        return [
            ID::make()->sortable(),
            Text::make('Title')
                ->sortable()
                ->rules('required', 'max:255'),
            Text::make('Slug')
                ->sortable()
                ->rules('required', 'unique:blog_posts,slug'),
            Textarea::make('Content')
                ->rules('required'),
            DateTime::make('Published At'),
        ];
    }
}

Frontend Implementation

Project Setup

npx create-next-app@latest blog-frontend --typescript --tailwind --app
cd blog-frontend

Blog List Page

// app/blog/page.tsx
import { Metadata } from 'next';

interface Post {
    id: string;
    title: string;
    slug: string;
    excerpt: string;
    publishedAt: string;
}

export const metadata: Metadata = {
    title: 'Blog Posts',
    description: 'Latest articles and updates'
};

async function fetchPosts(): Promise<Post[]> {
    const res = await fetch('${process.env.API_URL}/posts', {
        next: { revalidate: 3600 }
    });
    return res.json();
}

export default async function BlogPage() {
    const posts = await fetchPosts();

    return (
        <main className="max-w-4xl mx-auto py-8 px-4">
            <h1 className="text-3xl font-bold mb-8">Latest Posts</h1>
            <div className="grid gap-6">
                {posts.map(post => (
                    <article key={post.id} className="border rounded-lg p-6">
                        <h2 className="text-2xl font-semibold mb-2">
                            {post.title}
                        </h2>
                        <p className="text-gray-600">{post.excerpt}</p>
                        <time className="text-sm text-gray-500">
                            {new Date(post.publishedAt).toLocaleDateString()}
                        </time>
                    </article>
                ))}
            </div>
        </main>
    );
}

Single Post Page

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Metadata } from 'next';

interface Post {
    title: string;
    content: string;
    publishedAt: string;
}

export async function generateMetadata({
    params
}: {
    params: { slug: string }
}): Promise<Metadata> {
    const post = await fetchPost(params.slug);
    return {
        title: post.title,
        description: post.excerpt
    };
}

async function fetchPost(slug: string): Promise<Post> {
    const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
        next: { revalidate: 3600 }
    });

    if (!res.ok) notFound();
    return res.json();
}

export default async function PostPage({
    params
}: {
    params: { slug: string }
}) {
    const post = await fetchPost(params.slug);

    return (
        <article className="max-w-4xl mx-auto py-8 px-4">
            <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
            <time className="text-gray-500 mb-8 block">
                {new Date(post.publishedAt).toLocaleDateString()}
            </time>
            <div className="prose max-w-none"
                 dangerouslySetInnerHTML={{ __html: post.content }}
            />
        </article>
    );
}

API Integration

Laravel API Controller

// app/Http/Controllers/Api/BlogController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\BlogPost;
use Illuminate\Http\Request;

class BlogController extends Controller
{
    public function index()
    {
        return BlogPost::published()
            ->latest('published_at')
            ->paginate(12);
    }

    public function show($slug)
    {
        $post = BlogPost::where('slug', $slug)
            ->whereNotNull('published_at')
            ->firstOrFail();

        return response()->json($post);
    }
}

Performance Optimizations

Next.js Caching Strategy

// lib/cache.ts
export const postCacheConfig = {
  next: {
    revalidate: 3600, // Revalidate every hour
    tags: ['posts'],
  },
};

export async function revalidatePost(slug: string) {
  await fetch(`/api/revalidate?tag=post-${slug}`);
}

Laravel Query Optimization

// app/Models/BlogPost.php
public function scopePublished($query)
{
    return $query->whereNotNull('published_at')
        ->where('published_at', '<=', now())
        ->latest('published_at');
}

Error Handling

// app/blog/error.tsx
'use client';

export default function ErrorBoundary({
    error,
    reset
}: {
    error: Error;
    reset: () => void;
}) {
    return (
        <div className="text-center py-10">
            <h2 className="text-2xl font-bold mb-4">
                Something went wrong!
            </h2>
            <button
                onClick={reset}
                className="bg-blue-500 text-white px-4 py-2 rounded"
            >
                Try again
            </button>
        </div>
    );
}

Let's create a comprehensive blog system with Next.js 14 App Router and Laravel 11.

Project Structure

Next.js Directory Structure

app/
├── blog/
│   ├── [slug]/
│   │   ├── page.tsx
│   │   ├── loading.tsx
│   │   └── error.tsx
├── categories/
│   ├── [category]/
│   │   └── page.tsx
└── layout.tsx

Backend Implementation

Laravel Blog Post Model

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Post extends Model
{
    protected $fillable = [
        'title',
        'slug',
        'content',
        'excerpt',
        'category_id',
        'published_at',
        'featured_image'
    ];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function scopePublished($query)
    {
        return $query->whereNotNull('published_at')
                    ->where('published_at', '<=', now());
    }
}

API Controllers

// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        return Post::with('category')
            ->published()
            ->latest('published_at')
            ->paginate(12);
    }

    public function show($slug)
    {
        return Post::with('category')
            ->where('slug', $slug)
            ->published()
            ->firstOrFail();
    }

    public function byCategory($category)
    {
        return Post::whereHas('category', function($query) use ($category) {
            $query->where('slug', $category);
        })
        ->published()
        ->latest('published_at')
        ->paginate(12);
    }
}

Frontend Implementation

Blog List Page

// app/blog/page.tsx
import { Metadata } from 'next';

interface Post {
    id: number;
    title: string;
    slug: string;
    excerpt: string;
    featured_image: string;
    published_at: string;
    category: {
        name: string;
        slug: string;
    };
}

export const metadata: Metadata = {
    title: 'Blog Posts',
    description: 'Latest articles from our blog'
};

async function getPosts(page = 1): Promise<{
    data: Post[];
    meta: { total: number; current_page: number; last_page: number; }
}> {
    const res = await fetch(`${process.env.API_URL}/posts?page=${page}`, {
        next: { revalidate: 3600 }
    });
    if (!res.ok) throw new Error('Failed to fetch posts');
    return res.json();
}

export default async function BlogPage({
    searchParams
}: {
    searchParams: { page: string }
}) {
    const page = parseInt(searchParams.page) || 1;
    const { data: posts, meta } = await getPosts(page);

    return (
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
            <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
                {posts.map(post => (
                    <article key={post.id} className="bg-white rounded-lg shadow">
                        <img
                            src={post.featured_image}
                            alt={post.title}
                            className="w-full h-48 object-cover rounded-t-lg"
                        />
                        <div className="p-6">
                            <span className="text-blue-600 text-sm">
                                {post.category.name}
                            </span>
                            <h2 className="text-xl font-semibold mt-2">
                                {post.title}
                            </h2>
                            <p className="mt-2 text-gray-600">
                                {post.excerpt}
                            </p>
                        </div>
                    </article>
                ))}
            </div>
        </div>
    );
}

Single Post Page

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Metadata } from 'next';

interface Post {
    title: string;
    content: string;
    published_at: string;
    category: {
        name: string;
        slug: string;
    };
}

async function getPost(slug: string): Promise<Post> {
    const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
        next: { revalidate: 3600 }
    });
    if (!res.ok) notFound();
    return res.json();
}

export async function generateMetadata({
    params
}: {
    params: { slug: string }
}): Promise<Metadata> {
    const post = await getPost(params.slug);
    return {
        title: post.title,
        description: post.excerpt
    };
}

export default async function PostPage({
    params
}: {
    params: { slug: string }
}) {
    const post = await getPost(params.slug);

    return (
        <article className="max-w-4xl mx-auto px-4 py-12">
            <header className="mb-8">
                <h1 className="text-4xl font-bold">{post.title}</h1>
                <div className="mt-4 flex items-center text-gray-600">
                    <span>{post.category.name}</span>
                    <span className="mx-2">•</span>
                    <time>{new Date(post.published_at).toLocaleDateString()}</time>
                </div>
            </header>
            <div
                className="prose max-w-none"
                dangerouslySetInnerHTML={{ __html: post.content }}
            />
        </article>
    );
}

Category Page

// app/categories/[category]/page.tsx
import { Metadata } from 'next';

interface CategoryPost {
    id: number;
    title: string;
    slug: string;
    excerpt: string;
}

async function getCategoryPosts(category: string, page = 1): Promise<{
    data: CategoryPost[];
    meta: { total: number; current_page: number; }
}> {
    const res = await fetch(
        `${process.env.API_URL}/categories/${category}/posts?page=${page}`,
        { next: { revalidate: 3600 } }
    );
    if (!res.ok) throw new Error('Failed to fetch category posts');
    return res.json();
}

export async function generateMetadata({
    params
}: {
    params: { category: string }
}): Promise<Metadata> {
    return {
        title: `${params.category.charAt(0).toUpperCase() + params.category.slice(1)} Posts`,
        description: `Articles in the ${params.category} category`
    };
}

export default async function CategoryPage({
    params,
    searchParams
}: {
    params: { category: string };
    searchParams: { page: string }
}) {
    const page = parseInt(searchParams.page) || 1;
    const { data: posts } = await getCategoryPosts(params.category, page);

    return (
        <div className="max-w-7xl mx-auto px-4 py-12">
            <h1 className="text-3xl font-bold mb-8 capitalize">
                {params.category} Posts
            </h1>
            <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
                {posts.map(post => (
                    <article key={post.id} className="bg-white rounded-lg shadow p-6">
                        <h2 className="text-xl font-semibold">
                            {post.title}
                        </h2>
                        <p className="mt-2 text-gray-600">
                            {post.excerpt}
                        </p>
                    </article>
                ))}
            </div>
        </div>
    );
}

Data Fetching Utility

// lib/api.ts
export async function fetchAPI<T>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  const defaultOptions = {
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    next: { revalidate: 3600 },
  };

  const res = await fetch(`${process.env.API_URL}${endpoint}`, {
    ...defaultOptions,
    ...options,
  });

  if (!res.ok) {
    throw new Error(`API error: ${res.statusText}`);
  }

  return res.json();
}

This implementation provides a complete blog system with category support, pagination, and proper error handling. The Laravel backend serves as a robust API while Next.js handles the frontend with server-side rendering and static generation capabilities.

About Prateeksha Web Design

Prateeksha Web Design offers expert services for creating a robust blogging platform utilizing Laravel Nova and Next.js. Their team specializes in building customizable, scalable solutions that enhance user experience and performance. They focus on seamless integration between backend and frontend, ensuring dynamic content delivery. With a commitment to modern design principles, they tailor each project to meet client needs. Clients can expect ongoing support and maintenance for optimal platform functionality.

Interested in learning more? Contact us today.

Sumeet Shroff
Sumeet Shroff
Sumeet Shroff is an expert in blog development, specializing in creating robust blogging platforms using Laravel Nova and innovative Next.js UI.
Loading...