Getting Started with Backend Development in Next.js 15: A Beginner’s Guide to REST APIs



This content originally appeared on DEV Community and was authored by Fonyuy Gita

Transform your Express.js skills into powerful Next.js backend development with the modern App Router

Table of Contents

  • Introduction
  • Part 1 – Understanding the Next.js Backend
    • What Makes Next.js Backend Different?
    • Understanding Endpoints in Backend Context
    • API Types and Why We Choose REST
    • Next.js Backend Architecture Explained
    • Creating Your First Backend Endpoint
    • Request and Response Objects Deep Dive
    • Building a Complete Task Manager API
    • Connecting Frontend to Your API
  • What’s Coming Next

Introduction

If you’ve been building APIs with Express.js and Node.js, you already understand the fundamentals of backend development. You know how to handle routes, process requests, send responses, and structure your server-side logic. Now, it’s time to take that knowledge and elevate it with Next.js 15’s powerful App Router system.

Think of this transition like moving from a traditional restaurant kitchen to a modern, all-in-one culinary workspace. In Express, you had separate tools for every task – one for routing, another for middleware, yet another for serving static files. Next.js gives you an integrated environment where your frontend and backend work together seamlessly, like having a kitchen where every station connects perfectly to create a complete dining experience.

This tutorial focuses on Part 1 of our journey: understanding and building robust backend APIs with Next.js 15. We’ll create a complete Task Manager API that demonstrates all the core concepts you need to master before diving into authentication and database integration in the upcoming parts.

Part 1 – Understanding the Next.js Backend

What Makes Next.js Backend Different?

When you built APIs with Express.js, you probably created a structure something like this:

nextjs vs express

// Traditional Express setup
const express = require('express');
const app = express();

app.get('/api/tasks', (req, res) => {
  // Handle GET request
});

app.post('/api/tasks', (req, res) => {
  // Handle POST request
});

app.listen(3000);

This approach works wonderfully, but it requires you to manage separate frontend and backend applications. Next.js revolutionizes this by letting you build both your user interface and API endpoints in the same project, using the same development server, and deploying them together as a unified application.

With Next.js 15’s App Router, your API routes live alongside your page components in an intuitive file structure. Instead of defining routes in code, you define them through your folder structure. This means your project organization directly reflects your API structure, making it incredibly easy to understand and maintain.

The magic happens in the app directory, where a special file called route.js (or route.ts for TypeScript) transforms any folder into an API endpoint. Think of it as placing a “this is an API endpoint” sign in any directory you want to serve backend functionality.

Understanding Endpoints in Backend Context

Before we dive into building, let’s ensure we’re all speaking the same language about endpoints. An endpoint is essentially a specific URL path where your application can receive requests and send back responses. It’s like having different doors in a building – each door leads to a different room with different services.

In backend development, endpoints serve as the communication bridge between your frontend application and your server-side logic. When a user clicks a button to save a task, delete an item, or fetch data, the frontend sends a request to a specific endpoint, which processes that request and sends back the appropriate response.

Each endpoint typically handles specific types of HTTP methods:

GET requests are like asking a librarian for a specific book – you’re requesting information without changing anything. POST requests are like submitting a form to create a new library card – you’re providing data to create something new. PUT requests are like updating your library card information – you’re modifying existing data. DELETE requests are like asking to cancel your library card – you’re removing something from the system.

The beauty of Next.js endpoints is that they can handle multiple HTTP methods in a single file, making your API organization clean and logical. Instead of having separate files for each operation on a resource, you have one file that handles all operations for that resource.

API Types and Why We Choose REST

rest vs graph vs grp
As you explore backend development, you’ll encounter different architectural styles for building APIs. The three most common approaches are REST, GraphQL, and gRPC, each with its own strengths and use cases.

REST (Representational State Transfer) is like a well-organized library system. Each resource (books, authors, genres) has its own section, and you use standard methods (browse, check out, return) to interact with them. REST uses familiar HTTP methods and follows predictable patterns, making it intuitive for beginners and widely supported across all platforms.

GraphQL is more like having a personal research assistant. Instead of visiting different sections of the library, you tell your assistant exactly what information you need, and they bring back precisely that data in one trip. While powerful, GraphQL introduces complexity that can overwhelm beginners.

gRPC is like having a high-speed, specialized communication system between different departments of a large organization. It’s incredibly efficient but requires more setup and is typically used for system-to-system communication rather than frontend-to-backend communication.

For this tutorial, we’re choosing REST because it builds naturally on your Express.js experience, uses familiar HTTP concepts, and provides a gentle learning curve while still being production-ready for most applications. REST’s simplicity allows us to focus on understanding Next.js-specific concepts without getting overwhelmed by additional architectural complexity.

Next.js Backend Architecture Explained

backend

Understanding Next.js backend architecture requires thinking differently about how applications are deployed and executed. Unlike traditional Express applications that run continuously on a server, Next.js backends can operate in multiple modes depending on how you deploy them.

When you deploy to platforms like Vercel (Next.js’s primary platform), your API routes become serverless functions. Think of serverless functions like having a team of specialists who only come to work when they’re needed. When someone requests data from your API, the platform spins up a function, processes the request, sends the response, and then shuts down. This means you only pay for the actual computing time you use, and your application can automatically scale to handle thousands of requests without you managing servers.

However, Next.js is flexible. You can also deploy it as a traditional server using Node.js, where your application runs continuously, just like your Express apps did. This gives you the choice between serverless efficiency and traditional server control based on your specific needs.

The architecture can also support hybrid approaches where some routes are serverless functions while others run on persistent servers. This flexibility means you can start simple and evolve your architecture as your application grows.

For development, Next.js runs everything locally on your machine, simulating whichever deployment mode you choose. This means your development experience remains consistent regardless of how you plan to deploy your application.

Creating Your First Backend Endpoint

Now let’s get our hands dirty by creating your first Next.js API endpoint. The process is surprisingly straightforward once you understand the file-based routing system.

First, ensure you have a Next.js 15 project set up. If you don’t have one yet, create it with:

npx create-next-app@latest my-nextjs-backend
cd my-nextjs-backend

Make sure to choose “No” for TypeScript when prompted, since we’re focusing on JavaScript for this tutorial.

Next.js 15 uses the App Router by default, which means your project structure should have an app directory at the root level. Inside this directory, we’ll create our first API endpoint.

Create the following folder structure:

app/
  api/
    hello/
      route.js

The api folder is a convention that helps organize your backend endpoints separately from your page components. The hello folder becomes the endpoint path, and route.js is the special file that Next.js recognizes as containing your API logic.

Here’s your first endpoint:

// app/api/hello/route.js

// This function handles GET requests to /api/hello
export async function GET(request) {
  // Create a simple response object
  const responseData = {
    message: "Hello from Next.js backend!",
    timestamp: new Date().toISOString(),
    method: "GET"
  };

  // Return a JSON response with a 200 status code
  return Response.json(responseData);
}

// This function handles POST requests to /api/hello
export async function POST(request) {
  // Extract the body from the incoming request
  const body = await request.json();

  // Create a response that includes the received data
  const responseData = {
    message: "Received your POST request!",
    receivedData: body,
    timestamp: new Date().toISOString(),
    method: "POST"
  };

  // Return a JSON response
  return Response.json(responseData);
}

Notice how different this is from Express! Instead of defining routes in a central router file, each endpoint is a separate file with exported functions named after HTTP methods. This makes your API structure incredibly clear and maintainable.

Start your development server with npm run dev and visit http://localhost:3000/api/hello in your browser. You should see your GET response displayed as JSON. To test the POST endpoint, you can use a tool like Postman or create a simple frontend form.

The beauty of this system is that your file structure directly maps to your API routes. A file at app/api/users/profile/route.js automatically becomes accessible at /api/users/profile. This eliminates the confusion that can arise in Express when route definitions are scattered across multiple files.

Request and Response Objects Deep Dive

Understanding the request and response objects in Next.js is crucial for building robust APIs. While they share concepts with Express, Next.js uses the standard Web API Request and Response objects, which are more universal and future-proof.

The request object contains all the information about the incoming HTTP request. Here’s how you can extract different types of data:

// app/api/demo/route.js

export async function GET(request) {
  // Get the URL object for parsing query parameters
  const { searchParams } = new URL(request.url);

  // Extract individual query parameters
  const name = searchParams.get('name') || 'Anonymous';
  const age = searchParams.get('age') || 'Unknown';

  // Get request headers
  const userAgent = request.headers.get('user-agent');
  const contentType = request.headers.get('content-type');

  return Response.json({
    queryParams: { name, age },
    headers: { userAgent, contentType },
    method: request.method,
    url: request.url
  });
}

export async function POST(request) {
  try {
    // For JSON data
    const jsonData = await request.json();

    // You can also get form data like this:
    // const formData = await request.formData();

    // Or plain text:
    // const textData = await request.text();

    return Response.json({
      message: "Data received successfully",
      data: jsonData
    });
  } catch (error) {
    // Handle parsing errors
    return Response.json(
      { error: "Invalid JSON data" },
      { status: 400 }
    );
  }
}

The response object is what you send back to the client. Next.js provides several convenient ways to create responses:

export async function GET(request) {
  // Simple JSON response (most common)
  return Response.json({ message: "Success" });

  // JSON response with custom status code
  return Response.json(
    { error: "Not found" },
    { status: 404 }
  );

  // JSON response with custom headers
  return Response.json(
    { data: "Some data" },
    { 
      status: 200,
      headers: {
        'Custom-Header': 'CustomValue',
        'Cache-Control': 'no-cache'
      }
    }
  );

  // Plain text response
  return new Response('Hello World', {
    status: 200,
    headers: { 'Content-Type': 'text/plain' }
  });
}

One important difference from Express is that Next.js request and response handling is asynchronous by nature. This means you’ll often use await when extracting data from requests, and you always return a Response object rather than using methods like res.send() or res.json() that Express developers are familiar with.

Building a Complete Task Manager API

Now let’s apply everything we’ve learned by building a comprehensive Task Manager API. This will demonstrate CRUD (Create, Read, Update, Delete) operations and show how to structure a real-world API.

We’ll start by creating our data structure and basic CRUD operations. For now, we’ll use in-memory storage (an array) to keep things simple, but in Part 3, we’ll connect this to a real database.

First, create the API endpoint structure:

app/
  api/
    tasks/
      route.js
      [id]/
        route.js

The [id] folder uses Next.js dynamic routing, which allows us to handle requests like /api/tasks/123 where 123 is a task ID.

Here’s our main tasks endpoint:

// app/api/tasks/route.js

// In-memory storage for tasks (in a real app, this would be a database)
let tasks = [
  {
    id: 1,
    title: "Learn Next.js Backend",
    description: "Master API routes and backend development",
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  },
  {
    id: 2,
    title: "Build Task Manager",
    description: "Create a complete CRUD API for task management",
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  }
];

// Counter for generating new task IDs
let nextTaskId = 3;

// GET /api/tasks - Retrieve all tasks
export async function GET(request) {
  try {
    // Parse query parameters for filtering and pagination
    const { searchParams } = new URL(request.url);
    const completed = searchParams.get('completed');
    const limit = parseInt(searchParams.get('limit')) || 10;
    const offset = parseInt(searchParams.get('offset')) || 0;

    // Filter tasks if completed parameter is provided
    let filteredTasks = tasks;
    if (completed !== null) {
      const isCompleted = completed === 'true';
      filteredTasks = tasks.filter(task => task.completed === isCompleted);
    }

    // Apply pagination
    const paginatedTasks = filteredTasks.slice(offset, offset + limit);

    return Response.json({
      tasks: paginatedTasks,
      total: filteredTasks.length,
      limit: limit,
      offset: offset
    });
  } catch (error) {
    return Response.json(
      { error: "Failed to retrieve tasks" },
      { status: 500 }
    );
  }
}

// POST /api/tasks - Create a new task
export async function POST(request) {
  try {
    // Extract task data from request body
    const { title, description } = await request.json();

    // Validate required fields
    if (!title || title.trim().length === 0) {
      return Response.json(
        { error: "Task title is required" },
        { status: 400 }
      );
    }

    // Create new task object
    const newTask = {
      id: nextTaskId++,
      title: title.trim(),
      description: description ? description.trim() : "",
      completed: false,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    };

    // Add to our in-memory storage
    tasks.push(newTask);

    // Return the created task with 201 status
    return Response.json(newTask, { status: 201 });
  } catch (error) {
    return Response.json(
      { error: "Invalid request data" },
      { status: 400 }
    );
  }
}

Now let’s create the individual task endpoint for operations on specific tasks:

// app/api/tasks/[id]/route.js

// Import our tasks array (in a real app, this would come from a database)
// For simplicity, we'll redefine it here, but normally you'd share this data
let tasks = [
  {
    id: 1,
    title: "Learn Next.js Backend",
    description: "Master API routes and backend development",
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  },
  {
    id: 2,
    title: "Build Task Manager",
    description: "Create a complete CRUD API for task management",
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  }
];

// GET /api/tasks/[id] - Retrieve a specific task
export async function GET(request, { params }) {
  try {
    // Extract the task ID from the URL parameters
    const taskId = parseInt(params.id);

    // Find the task in our array
    const task = tasks.find(t => t.id === taskId);

    if (!task) {
      return Response.json(
        { error: "Task not found" },
        { status: 404 }
      );
    }

    return Response.json(task);
  } catch (error) {
    return Response.json(
      { error: "Invalid task ID" },
      { status: 400 }
    );
  }
}

// PUT /api/tasks/[id] - Update a specific task
export async function PUT(request, { params }) {
  try {
    const taskId = parseInt(params.id);
    const { title, description, completed } = await request.json();

    // Find the task index in our array
    const taskIndex = tasks.findIndex(t => t.id === taskId);

    if (taskIndex === -1) {
      return Response.json(
        { error: "Task not found" },
        { status: 404 }
      );
    }

    // Validate the update data
    if (title !== undefined && title.trim().length === 0) {
      return Response.json(
        { error: "Task title cannot be empty" },
        { status: 400 }
      );
    }

    // Update the task properties
    const updatedTask = {
      ...tasks[taskIndex],
      updatedAt: new Date().toISOString()
    };

    if (title !== undefined) updatedTask.title = title.trim();
    if (description !== undefined) updatedTask.description = description.trim();
    if (completed !== undefined) updatedTask.completed = Boolean(completed);

    // Replace the task in our array
    tasks[taskIndex] = updatedTask;

    return Response.json(updatedTask);
  } catch (error) {
    return Response.json(
      { error: "Invalid request data" },
      { status: 400 }
    );
  }
}

// DELETE /api/tasks/[id] - Delete a specific task
export async function DELETE(request, { params }) {
  try {
    const taskId = parseInt(params.id);

    // Find the task index in our array
    const taskIndex = tasks.findIndex(t => t.id === taskId);

    if (taskIndex === -1) {
      return Response.json(
        { error: "Task not found" },
        { status: 404 }
      );
    }

    // Remove the task from our array
    const deletedTask = tasks.splice(taskIndex, 1)[0];

    return Response.json({
      message: "Task deleted successfully",
      deletedTask: deletedTask
    });
  } catch (error) {
    return Response.json(
      { error: "Failed to delete task" },
      { status: 500 }
    );
  }
}

This Task Manager API demonstrates several important concepts:

Error Handling: Each endpoint includes proper error handling with appropriate HTTP status codes. This makes your API predictable and easier to debug.

Data Validation: We validate incoming data to ensure it meets our requirements before processing it. This prevents invalid data from corrupting our application state.

Dynamic Routing: The [id] folder shows how Next.js handles dynamic URL segments, making it easy to build RESTful APIs.

Query Parameters: The GET endpoint shows how to handle query parameters for filtering and pagination, making your API more flexible.

Proper HTTP Methods: Each operation uses the appropriate HTTP method, following REST conventions that make your API intuitive to use.

Connecting Frontend to Your API

Now that we have a working Task Manager API, let’s see how to connect it to a frontend interface. This will help you understand the complete request-response cycle and give you a practical way to test your API.

Create a simple frontend page to interact with your API:

// app/tasks/page.js

'use client';

import { useState, useEffect } from 'react';

export default function TasksPage() {
  // State for managing tasks and form data
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [newTask, setNewTask] = useState({ title: '', description: '' });
  const [error, setError] = useState('');

  // Fetch all tasks when the component loads
  useEffect(() => {
    fetchTasks();
  }, []);

  // Function to fetch tasks from our API
  async function fetchTasks() {
    try {
      setLoading(true);
      const response = await fetch('/api/tasks');

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();
      setTasks(data.tasks);
      setError('');
    } catch (err) {
      setError('Failed to fetch tasks: ' + err.message);
      console.error('Error fetching tasks:', err);
    } finally {
      setLoading(false);
    }
  }

  // Function to create a new task
  async function createTask(e) {
    e.preventDefault();

    if (!newTask.title.trim()) {
      setError('Task title is required');
      return;
    }

    try {
      const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title: newTask.title,
          description: newTask.description
        })
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || 'Failed to create task');
      }

      const createdTask = await response.json();

      // Add the new task to our state
      setTasks(prevTasks => [...prevTasks, createdTask]);

      // Clear the form
      setNewTask({ title: '', description: '' });
      setError('');
    } catch (err) {
      setError('Failed to create task: ' + err.message);
      console.error('Error creating task:', err);
    }
  }

  // Function to toggle task completion
  async function toggleTaskCompletion(task) {
    try {
      const response = await fetch(`/api/tasks/${task.id}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          ...task,
          completed: !task.completed
        })
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || 'Failed to update task');
      }

      const updatedTask = await response.json();

      // Update the task in our state
      setTasks(prevTasks =>
        prevTasks.map(t => t.id === task.id ? updatedTask : t)
      );
      setError('');
    } catch (err) {
      setError('Failed to update task: ' + err.message);
      console.error('Error updating task:', err);
    }
  }

  // Function to delete a task
  async function deleteTask(taskId) {
    if (!confirm('Are you sure you want to delete this task?')) {
      return;
    }

    try {
      const response = await fetch(`/api/tasks/${taskId}`, {
        method: 'DELETE'
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || 'Failed to delete task');
      }

      // Remove the task from our state
      setTasks(prevTasks => prevTasks.filter(t => t.id !== taskId));
      setError('');
    } catch (err) {
      setError('Failed to delete task: ' + err.message);
      console.error('Error deleting task:', err);
    }
  }

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
      <h1>Task Manager</h1>

      {/* Error Display */}
      {error && (
        <div style={{ 
          backgroundColor: '#fee', 
          color: '#c33', 
          padding: '10px', 
          borderRadius: '4px',
          marginBottom: '20px'
        }}>
          {error}
        </div>
      )}

      {/* New Task Form */}
      <form onSubmit={createTask} style={{ marginBottom: '30px' }}>
        <h2>Create New Task</h2>
        <div style={{ marginBottom: '10px' }}>
          <input
            type="text"
            placeholder="Task title"
            value={newTask.title}
            onChange={(e) => setNewTask(prev => ({ ...prev, title: e.target.value }))}
            style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
          />
          <textarea
            placeholder="Task description (optional)"
            value={newTask.description}
            onChange={(e) => setNewTask(prev => ({ ...prev, description: e.target.value }))}
            style={{ width: '100%', padding: '8px', height: '80px' }}
          />
        </div>
        <button 
          type="submit" 
          style={{ 
            backgroundColor: '#007cba', 
            color: 'white', 
            padding: '10px 20px', 
            border: 'none', 
            borderRadius: '4px',
            cursor: 'pointer'
          }}
        >
          Create Task
        </button>
      </form>

      {/* Tasks List */}
      <div>
        <h2>Your Tasks</h2>
        {loading ? (
          <p>Loading tasks...</p>
        ) : tasks.length === 0 ? (
          <p>No tasks yet. Create one above!</p>
        ) : (
          <div>
            {tasks.map(task => (
              <div 
                key={task.id} 
                style={{ 
                  border: '1px solid #ddd', 
                  borderRadius: '4px', 
                  padding: '15px', 
                  marginBottom: '10px',
                  backgroundColor: task.completed ? '#f0f8f0' : '#fff'
                }}
              >
                <h3 style={{ 
                  margin: '0 0 10px 0',
                  textDecoration: task.completed ? 'line-through' : 'none',
                  color: task.completed ? '#666' : '#000'
                }}>
                  {task.title}
                </h3>
                {task.description && (
                  <p style={{ margin: '0 0 10px 0', color: '#666' }}>
                    {task.description}
                  </p>
                )}
                <div style={{ fontSize: '12px', color: '#999', marginBottom: '10px' }}>
                  Created: {new Date(task.createdAt).toLocaleString()}
                  {task.updatedAt !== task.createdAt && (
                    <span> | Updated: {new Date(task.updatedAt).toLocaleString()}</span>
                  )}
                </div>
                <div>
                  <button
                    onClick={() => toggleTaskCompletion(task)}
                    style={{
                      backgroundColor: task.completed ? '#28a745' : '#ffc107',
                      color: task.completed ? 'white' : 'black',
                      border: 'none',
                      padding: '5px 10px',
                      borderRadius: '3px',
                      marginRight: '10px',
                      cursor: 'pointer'
                    }}
                  >
                    {task.completed ? 'Mark Incomplete' : 'Mark Complete'}
                  </button>
                  <button
                    onClick={() => deleteTask(task.id)}
                    style={{
                      backgroundColor: '#dc3545',
                      color: 'white',
                      border: 'none',
                      padding: '5px 10px',
                      borderRadius: '3px',
                      cursor: 'pointer'
                    }}
                  >
                    Delete
                  </button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

This frontend component demonstrates several important concepts for working with APIs:

Error Handling: The component properly handles and displays errors from API calls, making it easier to debug issues during development.

Loading States: We show loading indicators while API requests are in progress, providing better user experience.

Optimistic Updates: After successful operations, we immediately update the local state rather than refetching all data, making the interface feel more responsive.

Proper HTTP Methods: Each operation uses the correct HTTP method and includes appropriate headers, following best practices for API communication.

To see this in action, visit http://localhost:3000/tasks in your browser. You’ll have a fully functional task management interface that communicates with your Next.js backend API.

What’s Coming Next

Congratulations! You’ve successfully built your first complete Next.js backend API with full CRUD functionality. You now understand how Next.js App Router transforms backend development, making it more intuitive and integrated than traditional Express.js approaches.

In Part 2, we’ll add authentication to secure your API endpoints. You’ll learn how to implement JWT-based authentication, protect routes from unauthorized access, and handle user sessions across your application. This will transform your Task Manager from a simple demo into a production-ready application that can handle multiple users securely.

Part 3 will connect your API to a real database, replacing our in-memory storage with persistent data storage. You’ll see how to integrate MongoDB or PostgreSQL with your Next.js backend, handle database connections efficiently, and implement proper data modeling for scalable applications.

Each part builds on the previous one, so the Task Manager API you’ve created today will evolve into a complete, production-ready backend system. The foundation you’ve built today – understanding endpoints, request handling, CRUD operations, and frontend integration – will serve you well as we add these advanced features.

Take some time to experiment with your current Task Manager API. Try adding new fields to tasks, implementing search functionality, or creating additional endpoints. The more you practice with these fundamentals, the easier the advanced concepts in the upcoming parts will be to understand and implement.

Ready to continue your Next.js backend journey? Stay tuned for Part 2 where we’ll secure your API with authentication and take your task management system to the next level!


This content originally appeared on DEV Community and was authored by Fonyuy Gita