DEV Community

Cover image for Full-Stack with Next.js — From React Dev to Full-Stack Engineer
Kushang Tailor
Kushang Tailor

Posted on

Full-Stack with Next.js — From React Dev to Full-Stack Engineer

Read Time: ~15 minutes | The bridge from React UI library to production full-stack framework

Prerequisites: React fundamentals, hooks, state management (Parts 1–3)


🔗 Series Navigation

Part 1: Complete Guide from Zero to Production
Part 2: Advanced Hooks & State Management
Part 3: Performance Optimization
Part 4: Full-Stack with Next.js ← YOU ARE HERE
→ Part 5: Testing & Debugging (coming next)


📌 What You'll Learn

By the end of this guide, you'll be able to:

  • ✅ Understand what Next.js adds on top of React (and why it matters)
  • ✅ Navigate the App Router with file-based routing
  • ✅ Know when to use Server Components vs Client Components
  • ✅ Fetch data on the server without a separate backend
  • ✅ Build API routes that replace a Node/Express server
  • ✅ Optimize images and fonts automatically
  • ✅ Build and deploy a real blog platform to Vercel in under 10 minutes

🤔 What Is Next.js (And Why Does It Exist)?

React is a UI library—it renders components beautifully, but it has no opinion about routing, data fetching, server-side rendering, or deployment. You assemble all of that yourself.

Next.js is the full-stack framework built on React that makes those decisions for you. It's like React + routing + data fetching + a server + a build pipeline, all in one cohesive package.

React alone:
UI rendering ✅ | Routing ❌ | Data fetching ❌ | SSR ❌ | API ❌

Next.js (React + framework):
UI rendering ✅ | Routing ✅ | Data fetching ✅ | SSR ✅ | API ✅
Enter fullscreen mode Exit fullscreen mode

Why the Industry Adopted Next.js So Fast

By 2025, Next.js is used by Vercel, Netflix, TikTok, Twitch, Notion, and GitHub among thousands of production applications. The reasons are practical:

  • SEO out of the box — pages are server-rendered, so search engines see real content
  • No Express server needed — API routes live inside the same codebase
  • Automatic optimizations — images, fonts, and scripts are optimized for free
  • Deployment in seconds — Vercel hosting knows Next.js deeply

⚖️ Next.js vs The Alternatives

Criteria Create React App Vite Next.js Remix
Routing Manual (React Router) Manual Built-in Built-in
SSR / SSG
API Routes
SEO Poor (CSR only) Poor (CSR only) Excellent Excellent
Image Optimization Manual Manual Automatic Manual
Learning Curve Low Low Medium Medium
Bundle Splitting Manual Auto Auto Auto
Best For Prototypes SPAs, tools Full-stack apps Form-heavy apps
Vercel Deploy Manual Manual One click One click

The takeaway: Use Next.js whenever your app needs SEO, a backend, or server-side data. Use Vite for internal dashboards or SPAs where SEO doesn't matter.


🚀 Setup & Project Structure

Create a Next.js App

# Create project (accept all defaults)
npx create-next-app@latest my-blog
cd my-blog
npm run dev
Enter fullscreen mode Exit fullscreen mode

You'll be asked a few questions—say Yes to TypeScript (optional), Yes to Tailwind (optional), and Yes to the App Router (essential for 2025).

Project Structure (App Router)

my-blog/
├── app/                    # ← Heart of your app (App Router)
│   ├── layout.tsx          # Root layout (wraps every page)
│   ├── page.tsx            # Home page → renders at /
│   ├── globals.css         # Global styles
│   ├── about/
│   │   └── page.tsx        # About page → renders at /about
│   └── blog/
│       ├── page.tsx        # Blog list → renders at /blog
│       └── [slug]/
│           └── page.tsx    # Single post → renders at /blog/my-post
├── public/                 # Static assets (images, fonts, etc.)
├── components/             # Shared UI components
├── lib/                    # Utility functions, DB connections
├── next.config.js          # Next.js configuration
└── package.json
Enter fullscreen mode Exit fullscreen mode

The magic: file path = URL path. No router config, no <Route> components. Just create a file and the route exists.


📁 File-Based Routing (App Router)

Page Files

Every page.tsx inside app/ becomes a route:

// app/page.tsx → renders at /
export default function HomePage() {
  return <h1>Welcome Home</h1>;
}

// app/about/page.tsx → renders at /about
export default function AboutPage() {
  return <h1>About Us</h1>;
}

// app/blog/page.tsx → renders at /blog
export default function BlogListPage() {
  return <h1>All Posts</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Routes (Parameters in URLs)

// app/blog/[slug]/page.tsx → renders at /blog/any-post-title
interface Props {
  params: { slug: string };
}

export default function PostPage({ params }: Props) {
  return <h1>Post: {params.slug}</h1>;
  // /blog/hello-world → params.slug = "hello-world"
  // /blog/my-react-guide → params.slug = "my-react-guide"
}
Enter fullscreen mode Exit fullscreen mode

Layout Files (Shared UI Across Routes)

// app/layout.tsx → wraps EVERY page in your app
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <nav>
          <a href="/">Home</a>
          <a href="/blog">Blog</a>
          <a href="/about">About</a>
        </nav>
        <main>{children}</main>
        <footer>© 2025 My Blog</footer>
      </body>
    </html>
  );
}

// app/blog/layout.tsx → wraps only /blog/* routes
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="blog-container">
      <aside>
        <h3>Categories</h3>
        {/* Sidebar here */}
      </aside>
      <article>{children}</article>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why layouts are powerful: Your nav and footer render once and persist across page navigations—no re-mounting, no flicker.

Special Files at a Glance

Filename Purpose
page.tsx The visible UI for a route
layout.tsx Wrapper shared across child routes
loading.tsx Auto-shown while page data loads
error.tsx Error boundary for a route
not-found.tsx Custom 404 page
route.ts API endpoint (replaces Express route)

⚡ Server Components vs Client Components

This is the concept that trips up most React developers moving to Next.js.

Server Components (Default in App Router)

In Next.js App Router, every component is a Server Component by default. They run on the server, never in the browser.

// app/blog/page.tsx
// ✅ Server Component (no "use client" directive)
// Runs on the server — can access databases, env vars, file system

async function getBlogPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // Re-fetch every hour
  });
  return response.json();
}

export default async function BlogPage() {
  const posts = await getBlogPosts(); // Direct async/await — no useEffect!

  return (
    <div>
      <h1>Blog Posts</h1>
      {posts.map((post: Post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <a href={`/blog/${post.slug}`}>Read more </a>
        </article>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What Server Components can do:

  • async/await directly (no useEffect + useState loading dance)
  • ✅ Access databases and file system directly
  • ✅ Use secret env variables safely
  • ✅ Import heavy libraries without bloating the client bundle
  • ❌ No useState, useEffect, event handlers
  • ❌ No browser APIs (window, document)

Client Components ("use client" directive)

Add "use client" at the top when you need interactivity:

"use client"; // ← This one line switches to a Client Component

import { useState } from 'react';

export default function SearchBar({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        onSearch(e.target.value);
      }}
      placeholder="Search posts..."
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Client Components are for:

  • useState, useEffect, custom hooks
  • ✅ Event handlers (onClick, onChange)
  • ✅ Browser APIs
  • ✅ Third-party libraries needing the DOM
  • ❌ Direct database/file system access
  • ❌ Secret environment variables

The Golden Pattern: Server Shell + Client Islands

// app/blog/page.tsx — Server Component (default)
import SearchBar from '@/components/SearchBar'; // Client Component
import PostList from '@/components/PostList';   // Server Component

async function getPosts() {
  // Runs on server — safe to use secrets, DB, etc.
  const posts = await db.posts.findMany();
  return posts;
}

export default async function BlogPage() {
  const posts = await getPosts(); // Direct DB call — no API needed

  return (
    <div>
      {/* Client island — handles user input */}
      <SearchBar onSearch={(q) => console.log(q)} />

      {/* Server component — rendered on server */}
      <PostList posts={posts} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mental model: Server Components are the shell (fast, SEO-friendly, secure). Client Components are interactive islands inside that shell. Use the minimum number of Client Components necessary.


🌐 Data Fetching in Next.js

Three Patterns You'll Use

1. Static Data (SSG) — Build time, cached forever

// app/about/page.tsx
// Data fetched once at build time — ultra fast

export default async function AboutPage() {
  const team = await fetch('https://api.example.com/team').then(r => r.json());
  // This fetch runs ONCE when you build — result is cached as static HTML

  return (
    <div>
      {team.map((member: Member) => (
        <div key={member.id}>{member.name}</div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Revalidated Data (ISR) — Refreshes every N seconds

// app/blog/page.tsx
// Re-fetches every 60 seconds — fresh but still cached

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 } // Refresh every 60 seconds
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

3. Dynamic Data (SSR) — Every request, always fresh

// app/dashboard/page.tsx
// Runs on every request — real-time data

async function getUserData(userId: string) {
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    cache: 'no-store' // Never cache — always fresh
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Fetching Strategy Decision

Is data the same for everyone?
├─ Yes, never changes → Static (no revalidate)
├─ Yes, but updates sometimes → ISR (revalidate: N seconds)
└─ No, user-specific or real-time → Dynamic (cache: 'no-store')
Enter fullscreen mode Exit fullscreen mode

🔌 API Routes: Your Backend Lives Here

Next.js route.ts files replace Express, Fastify, or any separate Node server.

Basic CRUD API

// app/api/posts/route.ts → handles GET /api/posts & POST /api/posts
import { NextRequest, NextResponse } from 'next/server';

// In-memory store (use a real DB in production)
const posts = [
  { id: 1, title: 'Hello Next.js', slug: 'hello-nextjs' },
  { id: 2, title: 'Server Components', slug: 'server-components' },
];

// GET /api/posts
export async function GET() {
  return NextResponse.json(posts);
}

// POST /api/posts
export async function POST(request: NextRequest) {
  const body = await request.json();

  if (!body.title) {
    return NextResponse.json(
      { error: 'Title is required' },
      { status: 400 }
    );
  }

  const newPost = {
    id: posts.length + 1,
    title: body.title,
    slug: body.title.toLowerCase().replace(/\s+/g, '-'),
  };

  posts.push(newPost);
  return NextResponse.json(newPost, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

Dynamic API Routes

// app/api/posts/[id]/route.ts → handles /api/posts/1, /api/posts/42, etc.
import { NextRequest, NextResponse } from 'next/server';

interface Params {
  params: { id: string };
}

// GET /api/posts/1
export async function GET(_req: NextRequest, { params }: Params) {
  const post = await db.posts.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!post) {
    return NextResponse.json({ error: 'Post not found' }, { status: 404 });
  }

  return NextResponse.json(post);
}

// PATCH /api/posts/1
export async function PATCH(request: NextRequest, { params }: Params) {
  const body = await request.json();

  const updated = await db.posts.update({
    where: { id: parseInt(params.id) },
    data: { title: body.title },
  });

  return NextResponse.json(updated);
}

// DELETE /api/posts/1
export async function DELETE(_req: NextRequest, { params }: Params) {
  await db.posts.delete({ where: { id: parseInt(params.id) } });
  return NextResponse.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

The result: a full REST API, no separate server, no CORS headaches—everything in one codebase.


🖼️ Image & Font Optimization (Free Performance Wins)

The <Image> Component

Never use a plain <img> in Next.js—use next/image instead:

import Image from 'next/image';

// ❌ Plain HTML img — unoptimized
<img src="/hero.jpg" alt="Hero" width={1200} height={600} />

// ✅ Next.js Image — automatic WebP, lazy loading, no layout shift
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority     // Load this image eagerly (above the fold)
/>

// ✅ Fill mode for responsive containers
<div style={{ position: 'relative', height: '300px' }}>
  <Image
    src="/cover.jpg"
    alt="Post cover"
    fill
    style={{ objectFit: 'cover' }}
    sizes="(max-width: 768px) 100vw, 50vw"
  />
</div>
Enter fullscreen mode Exit fullscreen mode

What next/image gives you for free:

  • Converts to WebP/AVIF automatically (30-50% smaller)
  • Lazy loads by default (only loads when in viewport)
  • Prevents Cumulative Layout Shift (CLS)
  • Generates multiple sizes for different screen widths

Font Optimization

// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';

// Fonts are downloaded at build time — no runtime network request
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',      // Show fallback font until loaded
  variable: '--font-inter',
});

const firaCode = Fira_Code({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-fira-code',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
      <body style={{ fontFamily: 'var(--font-inter)' }}>
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: Google Fonts loaded the old way block rendering and cause layout shift. Next.js downloads fonts at build time—zero runtime latency, zero layout shift.


🏗️ Real-World: Building a Blog Platform

Let's put everything together and build a real blog with posts and an API.

Step 1: Project Setup

npx create-next-app@latest nextjs-blog
cd nextjs-blog
npm install gray-matter    # Parse markdown frontmatter
npm install next-mdx-remote # Render MDX content
npm run dev
Enter fullscreen mode Exit fullscreen mode

Step 2: Post Data (Markdown Files)

<!-- posts/hello-nextjs.md -->
---
title: "Hello Next.js"
date: "2025-06-01"
excerpt: "A quick intro to why Next.js changes everything."
author: "Your Name"
---

# Hello Next.js

This is my first post rendered from markdown...
Enter fullscreen mode Exit fullscreen mode

Step 3: Library to Read Posts

// lib/posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDir = path.join(process.cwd(), 'posts');

export function getAllPosts() {
  const slugs = fs.readdirSync(postsDir);

  return slugs
    .map((filename) => {
      const slug = filename.replace(/\.md$/, '');
      const fullPath = path.join(postsDir, filename);
      const fileContents = fs.readFileSync(fullPath, 'utf8');
      const { data } = matter(fileContents);

      return {
        slug,
        title: data.title as string,
        date: data.date as string,
        excerpt: data.excerpt as string,
        author: data.author as string,
      };
    })
    .sort((a, b) => (a.date > b.date ? -1 : 1));
}

export function getPostBySlug(slug: string) {
  const fullPath = path.join(postsDir, `${slug}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  return {
    slug,
    content,
    title: data.title as string,
    date: data.date as string,
    author: data.author as string,
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Blog List Page (Server Component)

// app/blog/page.tsx
import { getAllPosts } from '@/lib/posts';
import Link from 'next/link';

export const metadata = {
  title: 'Blog | My Next.js Blog',
  description: 'Thoughts on React, Next.js, and web development.',
};

export default function BlogPage() {
  // Direct file system access — no API call, no useEffect
  const posts = getAllPosts();

  return (
    <main>
      <h1>Blog</h1>
      <p>{posts.length} posts published</p>

      <div className="post-grid">
        {posts.map((post) => (
          <article key={post.slug}>
            <time>{new Date(post.date).toLocaleDateString()}</time>
            <h2>
              <Link href={`/blog/${post.slug}`}>{post.title}</Link>
            </h2>
            <p>{post.excerpt}</p>
            <span>{post.author}</span>
          </article>
        ))}
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Single Post Page (Dynamic Route)

// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/posts';
import { notFound } from 'next/navigation';

// Pre-generate all post pages at build time
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// Generate dynamic metadata per post
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = getPostBySlug(params.slug);
  return {
    title: `${post.title} | My Blog`,
    description: post.title,
  };
}

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

  if (!post) notFound(); // Shows app/not-found.tsx automatically

  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <time>{new Date(post.date).toLocaleDateString()}</time>
        <span>By {post.author}</span>
      </header>

      {/* Render markdown as HTML */}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: API Route for Posts

// app/api/posts/route.ts
import { getAllPosts } from '@/lib/posts';
import { NextResponse } from 'next/server';

// GET /api/posts — returns all posts as JSON
export async function GET() {
  const posts = getAllPosts();
  return NextResponse.json(posts);
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Loading & Error States

// app/blog/loading.tsx — Shows while blog page loads
export default function BlogLoading() {
  return (
    <div>
      {[1, 2, 3].map((i) => (
        <div key={i} className="post-skeleton">
          <div className="skeleton-title" />
          <div className="skeleton-text" />
        </div>
      ))}
    </div>
  );
}

// app/blog/error.tsx — Shows if blog page throws
'use client';

export default function BlogError({ error, reset }: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong loading the blog</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

🚀 Deploying to Vercel in Under 10 Minutes

Vercel is the company behind Next.js. Their platform knows Next.js deeply—deployment is as simple as pushing code.

Step 1: Push to GitHub

git init
git add .
git commit -m "Initial blog setup"
git branch -M main
git remote add origin https://github.com/yourusername/my-blog.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

Step 2: Connect to Vercel

  1. Go to vercel.com and sign up with GitHub
  2. Click "New Project" → Import your repo
  3. Vercel detects Next.js automatically—click Deploy That's it. Your app is live.

What Vercel Provides Automatically

✅ HTTPS certificate (SSL)
✅ Global CDN (fast worldwide)
✅ Automatic deploys on every git push
✅ Preview URLs for every pull request
✅ Environment variable management
✅ Edge Network for API routes
✅ Analytics dashboard
✅ Serverless functions (your API routes)
Enter fullscreen mode Exit fullscreen mode

Environment Variables

# .env.local (never commit this file)
DATABASE_URL=postgresql://user:pass@host/db
NEXT_PUBLIC_API_URL=https://api.example.com  # NEXT_PUBLIC_ = visible in browser
SECRET_API_KEY=abc123  # No prefix = server-only (secure)
Enter fullscreen mode Exit fullscreen mode
// Accessing env vars
const dbUrl = process.env.DATABASE_URL;           // Server only ✅
const apiUrl = process.env.NEXT_PUBLIC_API_URL;   // Both client + server ✅
Enter fullscreen mode Exit fullscreen mode

Add the same variables in Vercel's dashboard under Project → Settings → Environment Variables.


📊 Performance Wins You Get for Free

When you deploy to Vercel with Next.js:

Optimization Technique Gain
Static pages Pre-rendered HTML Near-instant load
Dynamic pages Edge SSR <100ms TTFB globally
Images WebP + lazy load 30-50% smaller
Fonts Build-time download 0ms font latency
JavaScript Auto code-splitting Smallest possible bundles
API Routes Serverless functions Scales to zero, instant cold start

💡 Final Thoughts: Next.js as a Career Investment

The jump from React developer to Next.js full-stack engineer is one of the highest-ROI moves you can make in 2025.

What changes:

  • You go from "I build UIs" to "I build and ship entire applications"
  • You stop needing a separate backend team for simple APIs
  • Your apps get SEO, performance, and scalability built in The learning order that makes sense:
React Fundamentals (Part 1)
        ↓
Advanced Hooks & State (Part 2)
        ↓
Performance Optimization (Part 3)
        ↓
Next.js Full-Stack ← YOU ARE HERE
        ↓
Testing (Part 5)
        ↓
Production-Grade React (Part 6)
Enter fullscreen mode Exit fullscreen mode

You're not adding a new tool—you're graduating to a platform.


🔗 Quick Resources


💬 What's Your Next.js Story?

Have you made the jump from React to Next.js? Are you still on the fence? What's holding you back? Drop your experience in the comments—especially if you've hit any gotchas with Server Components. That's usually where people get stuck first!


📖 Series Roadmap

Part 1: Complete Guide from Zero to Production
Part 2: Advanced Hooks & State Management
Part 3: Performance Optimization
Part 4: Full-Stack with Next.js ← YOU ARE HERE
→ Part 5: Testing & Debugging (coming next)

Coming in Part 5:

  • Unit testing with Jest & React Testing Library
  • Testing Server Components and API routes
  • Debugging with React DevTools and browser tools
  • Error boundaries in production
  • CI/CD pipeline basics

Happy shipping! 🚀

Top comments (0)