Zum Inhalt springen

Understanding Next.js 15: A Complete Guide for React Developers (PART 2)

Table of Contents

  1. Server Components vs Client Components: The Fundamental Shift
  2. Data Fetching in Next.js 15: Beyond useEffect
  3. Loading States and Error Handling Made Simple
  4. Styling Your Next.js Application
  5. The Next.js Image Component: Performance Magic
  6. Building Your First Real Application
  7. Deployment: From Code to Live Website

Server Components vs Client Components: The Fundamental Shift

Imagine you’re running a restaurant. In traditional React (like a self-service cafeteria), customers come in, look at a menu, order their food, wait while it’s prepared, and then eat. This is how Client-Side Rendering works – everything happens in the browser after the user arrives.

Now imagine a full-service restaurant where some of the meal preparation happens in the kitchen before the customer even sits down. When they arrive, part of their meal is already ready, and only the finishing touches happen at their table. This is exactly how Next.js Server Components work.

NEXTJS 15

Server Components run on the server during the build process or when a request is made. They have access to databases, file systems, and other server-only resources. The crucial part is that they render to HTML on the server, meaning users get fully-formed content immediately.

Client Components work exactly like traditional React components. They run in the browser, can use hooks like useState and useEffect, and handle user interactions. They’re perfect for dynamic, interactive parts of your application.

Here’s the beautiful part: you can mix both types seamlessly in the same application. Let’s see this in action with a practical example.

Create a new file called app/posts/page.js (notice we’re using .js instead of .tsx since you’re not familiar with TypeScript yet):

// This is a Server Component - it runs on the server
export default async function PostsPage() {
  // This fetch happens on the server, not in the browser
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Latest Posts</h1>
      <div className="grid gap-4">
        {posts.slice(0, 5).map(post => (
          <div key={post.id} className="border p-4 rounded-lg shadow">
            <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600">{post.body}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Notice something remarkable here: we’re using async/await directly in our component function. This isn’t possible in traditional React components! The fetch happens on the server, and users receive the fully-rendered HTML with the posts already loaded.

Now let’s add some interactivity with a Client Component. Create app/components/LikeButton.js:

'use client' // This directive tells Next.js this is a Client Component

import { useState } from 'react';

export default function LikeButton() {
  const [likes, setLikes] = useState(0);
  const [isLiked, setIsLiked] = useState(false);

  const handleLike = () => {
    if (isLiked) {
      setLikes(likes - 1);
    } else {
      setLikes(likes + 1);
    }
    setIsLiked(!isLiked);
  };

  return (
    <button 
      onClick={handleLike}
      className={`px-4 py-2 rounded ${isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
    >
      {isLiked ? '❤️' : '🤍'} {likes} likes
    </button>
  );
}

The key indicator of a Client Component is the 'use client' directive at the top. This tells Next.js that this component needs to run in the browser because it uses state and event handlers.

Now let’s combine both in our posts page. Update app/posts/page.js:

import LikeButton from '../components/LikeButton';

// Server Component - fetches data on the server
export default async function PostsPage() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Latest Posts</h1>
      <div className="grid gap-4">
        {posts.slice(0, 5).map(post => (
          <div key={post.id} className="border p-4 rounded-lg shadow">
            <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.body}</p>
            {/* Client Component for interactivity */}
            <LikeButton />
          </div>
        ))}
      </div>
    </div>
  );
}

This hybrid approach gives you the best of both worlds: fast initial loading with server-rendered content, plus rich interactivity where you need it. Think of it as having your cake and eating it too!

For a deeper dive into Server Components, check out this excellent video explanation: https://www.youtube.com/watch?v=TQQPAU21ZUw

Data Fetching in Next.js 15: Beyond useEffect

If you’ve been building React applications, you’re probably familiar with this pattern:

// The old React way - don't do this in Next.js!
function Posts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/posts')
      .then(res => res.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

This pattern works, but it has problems. The user sees a loading spinner, search engines can’t see the content, and the page feels slow. Next.js offers much better alternatives.

Server-Side Data Fetching happens before the page renders, so users get complete content immediately:

// The Next.js way - much better!
export default async function Posts() {
  // This runs on the server
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();

  // Users receive fully-rendered HTML
  return (
    <div>
      {posts.map(post => (
        <div key={post.id} className="mb-4 p-4 border rounded">
          <h3 className="font-bold">{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
}

But what if you need to fetch data based on user interaction? That’s where Client Components come in. Let’s build a search feature:

'use client'

import { useState } from 'react';

export default function SearchablePosts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');

  const searchPosts = async () => {
    if (!searchTerm.trim()) return;

    setLoading(true);
    try {
      // Search posts by user ID (simulating search)
      const response = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${searchTerm}`);
      const data = await response.json();
      setPosts(data);
    } catch (error) {
      console.error('Failed to search posts:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h2 className="text-2xl font-bold mb-4">Search Posts by User ID</h2>
      <div className="flex gap-2 mb-6">
        <input
          type="number"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Enter user ID (1-10)"
          className="border px-3 py-2 rounded flex-1"
          min="1"
          max="10"
        />
        <button 
          onClick={searchPosts}
          disabled={loading}
          className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {loading ? 'Searching...' : 'Search'}
        </button>
      </div>

      {posts.length > 0 && (
        <div>
          <h3 className="text-lg font-semibold mb-3">Found {posts.length} posts:</h3>
          {posts.map(post => (
            <div key={post.id} className="border p-4 rounded mb-3">
              <h4 className="font-medium">{post.title}</h4>
              <p className="text-gray-600 text-sm">{post.body}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

When to use each approach:

Use Server-Side fetching (async Server Components) when you need data that’s available at build time or request time, like blog posts, product listings, or user profiles. This gives you the fastest possible loading experience.

Use Client-Side fetching (useEffect in Client Components) when you need to fetch data based on user interactions, like search results, form submissions, or real-time updates.

For more advanced data fetching patterns, explore the official Next.js documentation: https://nextjs.org/docs/app/building-your-application/data-fetching

Loading States and Error Handling Made Simple

One of the most frustrating parts of building React applications is managing loading states and error handling. Next.js makes this dramatically simpler with special files that handle these states automatically.

Loading States are handled by creating a loading.js file in your route directory. This file automatically shows while your Server Component is fetching data:

Create app/posts/loading.js:

export default function Loading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="animate-pulse">
        <div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
        {[1, 2, 3, 4, 5].map(i => (
          <div key={i} className="border p-4 rounded-lg shadow mb-4">
            <div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
            <div className="h-4 bg-gray-200 rounded w-full mb-1"></div>
            <div className="h-4 bg-gray-200 rounded w-2/3"></div>
          </div>
        ))}
      </div>
    </div>
  );
}

This creates a skeleton loading screen that automatically appears while your posts are being fetched. The beauty is that Next.js handles all the timing for you.

Error Handling works similarly. Create app/posts/error.js:

'use client' // Error components must be Client Components

export default function Error({ error, reset }) {
  return (
    <div className="container mx-auto px-4 py-8 text-center">
      <div className="max-w-md mx-auto">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          Oops! Something went wrong
        </h2>
        <p className="text-gray-600 mb-4">
          We couldn't load the posts right now. This might be a temporary issue.
        </p>
        <div className="bg-red-50 border border-red-200 rounded p-4 mb-4">
          <p className="text-sm text-red-800">{error.message}</p>
        </div>
        <button
          onClick={reset} // This function retries the failed request
          className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
        >
          Try Again
        </button>
      </div>
    </div>
  );
}

Let’s create a deliberately failing example to see error handling in action. Create app/broken/page.js:

export default async function BrokenPage() {
  // This will fail and trigger our error boundary
  const response = await fetch('https://this-api-does-not-exist.com/posts');
  const posts = await response.json();

  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

And create app/broken/error.js with the same error component as above. When you visit /broken, you’ll see the error page instead of a broken application.

Not Found Pages are handled with not-found.js. Create app/not-found.js:

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900">404</h1>
        <p className="text-xl text-gray-600 mt-4">Page not found</p>
        <p className="text-gray-500 mt-2">The page you're looking for doesn't exist.</p>
        <a 
          href="/" 
          className="mt-6 inline-block bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600"
        >
          Go Home
        </a>
      </div>
    </div>
  );
}

This comprehensive error handling system means your users never see broken white screens or confusing error messages. Everything is handled gracefully with appropriate fallbacks.

Styling Your Next.js Application

Styling in Next.js is incredibly flexible. You have multiple approaches, and understanding when to use each one helps you build more maintainable applications.

Tailwind CSS (which you likely installed during setup) is a utility-first framework that lets you style directly in your JSX. It’s like having a comprehensive set of styling tools at your fingertips:

export default function StyledCard() {
  return (
    <div className="max-w-sm rounded overflow-hidden shadow-lg bg-white">
      <img 
        className="w-full h-48 object-cover" 
        src="https://picsum.photos/400/200" 
        alt="Sample" 
      />
      <div className="px-6 py-4">
        <div className="font-bold text-xl mb-2">Card Title</div>
        <p className="text-gray-700 text-base">
          This is a sample card built with Tailwind CSS. Notice how we can style 
          everything without writing separate CSS files.
        </p>
      </div>
      <div className="px-6 pt-4 pb-2">
        <span className="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
          #tailwind
        </span>
        <span className="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
          #nextjs
        </span>
      </div>
    </div>
  );
}

CSS Modules let you write traditional CSS but with automatic scoping. Create components/Button.module.css:

.button {
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
}

.primary {
  background-color: #3b82f6;
  color: white;
}

.primary:hover {
  background-color: #2563eb;
  transform: translateY(-1px);
}

.secondary {
  background-color: #f3f4f6;
  color: #374151;
}

.secondary:hover {
  background-color: #e5e7eb;
}

Then use it in your component:

import styles from './Button.module.css';

export default function Button({ children, variant = 'primary', ...props }) {
  return (
    <button 
      className={`${styles.button} ${styles[variant]}`}
      {...props}
    >
      {children}
    </button>
  );
}

Global Styles go in your app/globals.css file. This is perfect for typography, CSS resets, and site-wide styling:

/* Add to app/globals.css */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

/* Custom global styles */
html {
  scroll-behavior: smooth;
}

body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}

/* Custom component classes using Tailwind */
@layer components {
  .btn-primary {
    @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded;
  }

  .card {
    @apply bg-white shadow-md rounded-lg p-6 mb-4;
  }
}

For inspiration and advanced Tailwind techniques, check out Tailwind UI: https://tailwindui.com/components

The Next.js Image Component: Performance Magic

Images often make up the largest portion of a website’s data transfer, but most developers handle them poorly. The Next.js Image component automatically optimizes images for performance, accessibility, and user experience.

Here’s the difference between a regular HTML image and the Next.js Image component:

// Regular HTML image - not optimized
function RegularImage() {
  return <img src="/large-image.jpg" alt="A large image" />;
}

// Next.js Image - automatically optimized
import Image from 'next/image';

function OptimizedImage() {
  return (
    <Image
      src="/large-image.jpg"
      alt="A large image"
      width={800}
      height={600}
      priority // Load this image first (for above-the-fold images)
    />
  );
}

But the real magic happens when you use external images. Let’s build a photo gallery using the JSONPlaceholder API:

import Image from 'next/image';

export default async function PhotoGallery() {
  const response = await fetch('https://jsonplaceholder.typicode.com/photos?_limit=12');
  const photos = await response.json();

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">Photo Gallery</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {photos.map(photo => (
          <div key={photo.id} className="bg-white rounded-lg shadow-lg overflow-hidden">
            <div className="relative h-48">
              <Image
                src={photo.url}
                alt={photo.title}
                fill // This makes the image fill its container
                className="object-cover hover:scale-105 transition-transform duration-300"
              />
            </div>
            <div className="p-4">
              <h3 className="font-semibold text-lg mb-2">{photo.title}</h3>
              <p className="text-gray-600 text-sm">Photo #{photo.id}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

But wait, there’s a problem! External images need to be configured in your next.config.js file for security reasons. Update your config:

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['via.placeholder.com'], // Allow images from JSONPlaceholder
  },
}

module.exports = nextConfig;

The Next.js Image component automatically provides lazy loading, responsive images, WebP conversion (when supported), and proper sizing. It’s like having a professional image optimization service built into your framework.

Here are the key props you’ll use most often:

<Image
  src="/image.jpg"
  alt="Description" // Always required for accessibility
  width={400}      // Required for static images
  height={300}     // Required for static images
  fill            // Alternative to width/height - fills container
  priority        // Load immediately (for above-fold images)
  placeholder="blur" // Shows blur while loading
  blurDataURL="data:..." // Custom blur placeholder
  sizes="(max-width: 768px) 100vw, 50vw" // Responsive sizing
  className="rounded-lg" // Standard className support
/>

Learn more about advanced image optimization: https://nextjs.org/docs/app/api-reference/components/image

Building Your First Real Application

Let’s build a complete blog application that demonstrates everything we’ve learned. This will be a fully functional blog with posts, comments, and search functionality using real APIs.

First, create the main blog page at app/blog/page.js:

import Image from 'next/image';
import Link from 'next/link';

export default async function BlogPage() {
  // Fetch posts and users data
  const [postsResponse, usersResponse] = await Promise.all([
    fetch('https://jsonplaceholder.typicode.com/posts'),
    fetch('https://jsonplaceholder.typicode.com/users')
  ]);

  const posts = await postsResponse.json();
  const users = await usersResponse.json();

  // Create a map of users for easy lookup
  const usersMap = users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
  }, {});

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <header className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          Welcome to Our Blog
        </h1>
        <p className="text-xl text-gray-600">
          Discover amazing stories and insights from our community
        </p>
      </header>

      <div className="grid gap-8">
        {posts.slice(0, 10).map(post => {
          const author = usersMap[post.userId];
          return (
            <article key={post.id} className="card border-l-4 border-blue-500">
              <div className="flex items-start space-x-4">
                <div className="flex-shrink-0">
                  <div className="w-12 h-12 bg-gray-300 rounded-full flex items-center justify-center">
                    <span className="text-lg font-bold text-gray-600">
                      {author?.name?.charAt(0) || '?'}
                    </span>
                  </div>
                </div>
                <div className="flex-1">
                  <div className="flex items-center space-x-2 text-sm text-gray-500 mb-2">
                    <span>By {author?.name || 'Unknown Author'}</span>
                    <span></span>
                    <span>{author?.email || 'No email'}</span>
                  </div>
                  <h2 className="text-2xl font-bold text-gray-900 mb-3 capitalize">
                    <Link 
                      href={`/blog/${post.id}`}
                      className="hover:text-blue-600 transition-colors"
                    >
                      {post.title}
                    </Link>
                  </h2>
                  <p className="text-gray-600 leading-relaxed">
                    {post.body}
                  </p>
                  <div className="mt-4">
                    <Link 
                      href={`/blog/${post.id}`}
                      className="text-blue-600 hover:text-blue-800 font-medium"
                    >
                      Read more →
                    </Link>
                  </div>
                </div>
              </div>
            </article>
          );
        })}
      </div>
    </div>
  );
}

Now create individual blog post pages at app/blog/[id]/page.js:

import Link from 'next/link';
import CommentSection from '../../components/CommentSection';

// This function tells Next.js which IDs are valid for this dynamic route
export async function generateStaticParams() {
  const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());

  // Return the first 10 post IDs for static generation
  return posts.slice(0, 10).map((post) => ({
    id: post.id.toString(),
  }));
}

export default async function BlogPost({ params }) {
  const postId = params.id;

  // Fetch post, author, and comments data
  const [postResponse, usersResponse, commentsResponse] = await Promise.all([
    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`),
    fetch('https://jsonplaceholder.typicode.com/users'),
    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`)
  ]);

  const post = await postResponse.json();
  const users = await usersResponse.json();
  const comments = await commentsResponse.json();

  const author = users.find(user => user.id === post.userId);

  return (
    <div className="max-w-3xl mx-auto px-4 py-8">
      {/* Navigation */}
      <nav className="mb-8">
        <Link href="/blog" className="text-blue-600 hover:text-blue-800">
          ← Back to Blog
        </Link>
      </nav>

      {/* Article Header */}
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 mb-4 capitalize">
          {post.title}
        </h1>
        <div className="flex items-center space-x-4 text-gray-600">
          <div className="flex items-center space-x-2">
            <div className="w-10 h-10 bg-gray-300 rounded-full flex items-center justify-center">
              <span className="font-bold">{author?.name?.charAt(0) || '?'}</span>
            </div>
            <div>
              <p className="font-medium">{author?.name || 'Unknown Author'}</p>
              <p className="text-sm">{author?.email || 'No email'}</p>
            </div>
          </div>
        </div>
      </header>

      {/* Article Content */}
      <article className="prose prose-lg max-w-none mb-12">
        <p className="text-lg text-gray-700 leading-relaxed">
          {post.body}
        </p>

        {/* Simulated additional content */}
        <p className="text-gray-700 leading-relaxed mt-6">
          This is where the full blog post content would continue. In a real application, 
          you would have much more rich content, images, and formatting. The JSONPlaceholder 
          API only provides brief sample content, but you can imagine this being a full 
          article with multiple paragraphs, headings, and media.
        </p>
      </article>

      {/* Author Info */}
      <div className="bg-gray-50 p-6 rounded-lg mb-8">
        <h3 className="text-lg font-bold mb-2">About the Author</h3>
        <div className="flex items-start space-x-4">
          <div className="w-16 h-16 bg-gray-300 rounded-full flex items-center justify-center">
            <span className="text-xl font-bold">{author?.name?.charAt(0) || '?'}</span>
          </div>
          <div>
            <h4 className="font-medium">{author?.name || 'Unknown Author'}</h4>
            <p className="text-gray-600 mb-2">{author?.email || 'No email'}</p>
            <p className="text-sm text-gray-500">
              Works at {author?.company?.name || 'Unknown Company'} • 
              Lives in {author?.address?.city || 'Unknown City'}
            </p>
          </div>
        </div>
      </div>

      {/* Comments Section */}
      <CommentSection comments={comments} postId={postId} />
    </div>
  );
}

Create the comment section component
app/components/CommentSection.js file:

'use client'

import { useState } from 'react';

export default function CommentSection({ comments, postId }) {
  const [newComment, setNewComment] = useState('');
  const [newCommentName, setNewCommentName] = useState('');
  const [localComments, setLocalComments] = useState(comments);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!newComment.trim() || !newCommentName.trim()) return;

    // Create a new comment object
    const comment = {
      id: Date.now(), // Simple ID generation for demo purposes
      postId: parseInt(postId),
      name: newCommentName,
      email: 'user@example.com', // Simulated email
      body: newComment
    };

    // Add the new comment to our local state
    setLocalComments([...localComments, comment]);
    setNewComment('');
    setNewCommentName('');
  };

  return (
    <div>
      <h2 className="text-2xl font-bold mb-6">Comments ({localComments.length})</h2>

      {/* Comment Form */}
      <form onSubmit={handleSubmit} className="bg-white p-6 rounded-lg border mb-8">
        <h3 className="text-lg font-medium mb-4">Leave a Comment</h3>
        <div className="mb-4">
          <input
            type="text"
            placeholder="Your name"
            value={newCommentName}
            onChange={(e) => setNewCommentName(e.target.value)}
            className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          />
        </div>
        <div className="mb-4">
          <textarea
            placeholder="Write your comment here..."
            value={newComment}
            onChange={(e) => setNewComment(e.target.value)}
            rows={4}
            className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          />
        </div>
        <button
          type="submit"
          className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
        >
          Post Comment
        </button>
      </form>

      {/* Comments List */}
      <div className="space-y-6">
        {localComments.map(comment => (
          <div key={comment.id} className="bg-white p-6 rounded-lg border">
            <div className="flex items-start space-x-3">
              <div className="w-10 h-10 bg-blue-500 text-white rounded-full flex items-center justify-center">
                <span className="font-bold">{comment.name.charAt(0).toUpperCase()}</span>
              </div>
              <div className="flex-1">
                <div className="flex items-center space-x-2 mb-1">
                  <h4 className="font-medium text-gray-900">{comment.name}</h4>
                  <span className="text-sm text-gray-500"></span>
                  <span className="text-sm text-gray-500">{comment.email}</span>
                </div>
                <p className="text-gray-700 leading-relaxed">{comment.body}</p>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Now let’s add a search functionality to our blog. Create app/components/BlogSearch.js:

'use client'

import { useState, useEffect } from 'react';
import Link from 'next/link';

export default function BlogSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [searchResults, setSearchResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);
  const [allPosts, setAllPosts] = useState([]);

  // Load all posts when component mounts
  useEffect(() => {
    async function loadPosts() {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        const posts = await response.json();
        setAllPosts(posts);
      } catch (error) {
        console.error('Failed to load posts:', error);
      }
    }

    loadPosts();
  }, []);

  // Search function that filters posts based on title and body
  useEffect(() => {
    if (searchTerm.trim() === '') {
      setSearchResults([]);
      setIsSearching(false);
      return;
    }

    setIsSearching(true);

    // Simulate search delay for better user experience
    const timeoutId = setTimeout(() => {
      const filtered = allPosts.filter(post =>
        post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
        post.body.toLowerCase().includes(searchTerm.toLowerCase())
      );

      setSearchResults(filtered.slice(0, 5)); // Limit to 5 results
      setIsSearching(false);
    }, 300); // 300ms delay to avoid too many searches while typing

    return () => clearTimeout(timeoutId);
  }, [searchTerm, allPosts]);

  return (
    <div className="relative max-w-md mx-auto mb-8">
      <div className="relative">
        <input
          type="text"
          placeholder="Search blog posts..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          className="w-full px-4 py-3 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        <div className="absolute inset-y-0 right-0 flex items-center pr-3">
          {isSearching ? (
            <div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full"></div>
          ) : (
            <svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
            </svg>
          )}
        </div>
      </div>

      {/* Search Results Dropdown */}
      {searchTerm && (
        <div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg">
          {searchResults.length > 0 ? (
            <div className="py-2">
              {searchResults.map(post => (
                <Link
                  key={post.id}
                  href={`/blog/${post.id}`}
                  className="block px-4 py-3 hover:bg-gray-50 transition-colors"
                  onClick={() => {
                    setSearchTerm('');
                    setSearchResults([]);
                  }}
                >
                  <h4 className="font-medium text-gray-900 capitalize truncate">
                    {post.title}
                  </h4>
                  <p className="text-sm text-gray-600 truncate mt-1">
                    {post.body}
                  </p>
                </Link>
              ))}
            </div>
          ) : !isSearching ? (
            <div className="px-4 py-3 text-sm text-gray-500">
              No posts found for "{searchTerm}"
            </div>
          ) : null}
        </div>
      )}
    </div>
  );
}

Now let’s update our main blog page to include the search functionality. Update app/blog/page.js:

import Image from 'next/image';
import Link from 'next/link';
import BlogSearch from '../components/BlogSearch';

export default async function BlogPage() {
  // Fetch posts and users data in parallel for better performance
  const [postsResponse, usersResponse] = await Promise.all([
    fetch('https://jsonplaceholder.typicode.com/posts'),
    fetch('https://jsonplaceholder.typicode.com/users')
  ]);

  const posts = await postsResponse.json();
  const users = await usersResponse.json();

  // Create a lookup map for users to avoid nested loops
  const usersMap = users.reduce((acc, user) => {
    acc[user.id] = user;
    return acc;
  }, {});

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <header className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          Welcome to Our Blog
        </h1>
        <p className="text-xl text-gray-600 mb-8">
          Discover amazing stories and insights from our community
        </p>

        {/* Add the search component */}
        <BlogSearch />
      </header>

      <div className="grid gap-8">
        {posts.slice(0, 10).map(post => {
          const author = usersMap[post.userId];
          return (
            <article key={post.id} className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500 hover:shadow-lg transition-shadow">
              <div className="flex items-start space-x-4">
                <div className="flex-shrink-0">
                  <div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-full flex items-center justify-center">
                    <span className="text-lg font-bold">
                      {author?.name?.charAt(0) || '?'}
                    </span>
                  </div>
                </div>
                <div className="flex-1">
                  <div className="flex items-center space-x-2 text-sm text-gray-500 mb-2">
                    <span>By {author?.name || 'Unknown Author'}</span>
                    <span></span>
                    <span>{author?.email || 'No email'}</span>
                    <span></span>
                    <span>Post #{post.id}</span>
                  </div>
                  <h2 className="text-2xl font-bold text-gray-900 mb-3 capitalize">
                    <Link 
                      href={`/blog/${post.id}`}
                      className="hover:text-blue-600 transition-colors"
                    >
                      {post.title}
                    </Link>
                  </h2>
                  <p className="text-gray-600 leading-relaxed mb-4">
                    {post.body.length > 150 ? `${post.body.substring(0, 150)}...` : post.body}
                  </p>
                  <div className="flex items-center justify-between">
                    <Link 
                      href={`/blog/${post.id}`}
                      className="inline-flex items-center text-blue-600 hover:text-blue-800 font-medium"
                    >
                      Read full article 
                      <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
                      </svg>
                    </Link>
                    <span className="text-sm text-gray-400">
                      {Math.ceil(post.body.length / 200)} min read
                    </span>
                  </div>
                </div>
              </div>
            </article>
          );
        })}
      </div>

      {/* Pagination hint for future enhancement */}
      <div className="mt-12 text-center">
        <p className="text-gray-500">
          Showing first 10 posts • In a real application, you'd implement pagination here
        </p>
      </div>
    </div>
  );
}

Deployment: From Code to Live Website

Getting your Next.js application from your local development environment to a live website that anyone can visit is surprisingly straightforward. Next.js was created by Vercel, and they’ve made deployment incredibly smooth.

Vercel Deployment (Recommended)

Vercel is the easiest way to deploy Next.js applications. Think of it as GitHub for hosting – you connect your code repository, and Vercel automatically builds and deploys your site whenever you push changes.

Here’s the step-by-step process:

First, make sure your code is in a Git repository. If you haven’t already, initialize Git in your project:

# In your project directory
git init
git add .
git commit -m "Initial commit"

Then, push your code to GitHub, GitLab, or Bitbucket. If you’re using GitHub:

# Create a new repository on GitHub first, then:
git remote add origin https://github.com/yourusername/your-blog-app.git
git branch -M main
git push -u origin main

Next, visit vercel.com and sign up with your GitHub account. Click „New Project“ and import your repository. Vercel automatically detects that you’re using Next.js and configures everything for you.

The deployment process happens in three steps that Vercel handles automatically:

  1. Build: Vercel runs npm run build to create optimized production files
  2. Deploy: Your application is distributed across Vercel’s global CDN
  3. Assign URL: You get a unique URL like your-blog-app.vercel.app

Within minutes, your application is live and accessible worldwide. Even better, every time you push changes to your repository, Vercel automatically rebuilds and redeploys your site.

Environment Variables for Production

If your application uses environment variables (for API keys, database connections, etc.), add them in your Vercel dashboard under Project Settings > Environment Variables. For our blog example, we don’t need any environment variables since we’re using public APIs.

Custom Domains

Once your site is deployed, you can add a custom domain. In your Vercel dashboard, go to Settings > Domains and add your domain name. Vercel provides free SSL certificates automatically.

Other Deployment Options

While Vercel is the most seamless option, Next.js applications can be deployed anywhere that supports Node.js:

Netlify: Similar to Vercel, with automatic Git deployments and a generous free tier.

Railway: Great for applications that need databases, with simple environment variable management.

Traditional Hosting: You can build your Next.js app (npm run build) and deploy the output to any static hosting provider, though you’ll lose server-side features.

The beauty of Next.js is that it’s deployment-agnostic. Your code works the same way whether it’s running on Vercel, Netlify, or your own server.

What’s Coming in Part 3

In Part 3, we’ll explore advanced data fetching patterns, caching strategies, and how to handle real-time data in Next.js applications. We’ll also dive into form handling, API routes, and building full-stack features within your Next.js application. The foundation you’ve built in these first two parts will support increasingly sophisticated applications as we continue this journey together.—

Congratulations! You’ve now built a complete, functional blog application with Next.js 15. You understand the fundamental concepts of Server Components, Client Components, data fetching, error handling, and deployment.

This application demonstrates real-world patterns you’ll use in every Next.js project. The combination of server-side rendering for performance and client-side interactivity for user experience is what makes Next.js so powerful for modern web development.

Take some time to experiment with the code, try adding new features, and make the application your own. The best way to solidify your understanding is through practice and exploration.

Ready for the next level? Part 3 will transform you from a Next.js beginner into someone who can build production-ready, scalable applications with confidence.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert