Task Routes in Remix

Introduction: Why We Need Task Routes

Welcome back! In the previous lesson, you built a service layer that neatly organizes all your task-related logic. Each function in that layer—such as getAllTasks, createTask, and deleteTask—focuses on a specific operation. This separation made your code cleaner, easier to test, and more scalable.

Now, it’s time to make your API routes actually use that service layer. In this lesson, you’ll connect those backend functions to real HTTP endpoints so users (or frontend apps) can retrieve, create, update, and delete tasks via HTTP requests.

There are two main types of task routes you’ll set up:

  • Collection routes: Handle the entire list of tasks. For example, fetching all tasks or adding a new one.
  • Item routes: Handle one task at a time, like retrieving, updating, or deleting a specific task.

By the end of this lesson, you’ll know exactly how to use your service layer in both route types, producing a clean, consistent, and testable API.

Quick Recap: Service Layer and Setup

Your routes rely on several important imports from the previous lesson. These include response helpers, service functions, and validation utilities that help your routes stay organized and reliable:

import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { ok, err } from "~/lib/responses";
import {
  createTask,
  filterTasksByCompletion,
  getAllTasks,
  getTaskById,
  deleteTask,
  patchTask,
  replaceTask
} from "~/lib/services/taskService";
import { validateTaskPayload } from "~/lib/taskValidation";
import { withLogging } from "~/utils/withLogging.server";
  • Response helpers (ok and err): Standardize success and error responses, ensuring that your API always sends predictable JSON objects with proper status codes.
  • Service functions: Perform the actual logic for reading, creating, updating, and deleting tasks. Your routes never manipulate data directly—they just call these functions.
  • Validation utility (validateTaskPayload): Ensures incoming data meets the required format before passing it to your service layer.
  • Logging wrapper (withLogging): Logs every request to make debugging easier and to track how your API is being used.

Collection Route: Working with All Tasks (`/api/tasks`)

The collection route handles operations that affect all tasks—retrieving the full list and adding new entries. In Remix, this logic goes in the file app/routes/api.tasks.tsx.

Here’s how it works:

export async function loader({ request }: LoaderFunctionArgs) {
  return withLogging("GET /api/tasks", async () => {
    const url = new URL(request.url);
    const completed = url.searchParams.get("completed");

    let result = getAllTasks();
    if (completed !== null) {
      if (completed !== "true" && completed !== "false") {
        return err("Invalid 'completed' filter. Use true or false.", 400);
      }
      result = filterTasksByCompletion(completed === "true");
    }

    return ok(result, 200, {
      total: result.length,
      filters: { completed: completed ?? null }
    });
  });
}

export async function action({ request }: ActionFunctionArgs) {
  return withLogging(`${request.method} /api/tasks`, async () => {
    if (request.method !== "POST") {
      return err("Method Not Allowed", 405);
    }

    try {
      const body = await request.json();
      const errors = validateTaskPayload(body, { requireAll: true });
      if (errors.length > 0) {
        return err(errors, 400);
      }

      const created = createTask({
        title: body.title,
        description: body.description,
        completed: body.completed,
        dueDate: body.dueDate
      });

      return ok(created, 201);
    } catch {
      return err("Invalid JSON body", 400);
    }
  });
}

Detailed Breakdown

  • The loader handles GET requests. It retrieves all tasks using getAllTasks(). If a query parameter like ?completed=true is provided, it filters the results with filterTasksByCompletion(). This approach allows flexible fetching without creating multiple endpoints.
  • Filter validation ensures correct query input. The code checks that completed is either "true" or "false". If the parameter is invalid, it immediately returns a 400 Bad Request response. This prevents broken filters and keeps API behavior predictable.
  • The response includes metadata to make the API more informative. Each success response includes a meta object that shows the total number of returned tasks and any active filters.
  • The action handles POST requests. It first confirms the method is POST, then reads and parses the request body to extract task details before passing them to validateTaskPayload.
  • Validation before creation ensures that all required fields are present and properly formatted. If any issue is found—like a missing title or invalid date—it returns a structured error response instead of creating bad data.
  • Finally, creating a new task calls createTask() to build a new task object and append it to memory. The function returns the created record with a 201 Created response.

Example GET output:

{
  "data": [
    {
      "id": 1,
      "title": "Draft course outline",
      "description": "First pass on the Task Manager API lesson flow",
      "completed": false,
      "createdAt": "2024-06-01T12:00:00Z"
    }
  ],
  "meta": {
    "total": 1,
    "filters": {
      "completed": null
    }
  }
}

Example POST error output:

{
  "error": ["'title' is required and must be a string"]
}

Item Route: Working with a Single Task (`/api/tasks/:id`)

The item route handles requests for a single task—retrieving it by ID, updating it, or deleting it. This logic goes in app/routes/api.tasks.$id.tsx.

Here’s the complete implementation:

export async function loader({ request, params }: LoaderFunctionArgs) {
  return withLogging("GET /api/tasks/:id", async () => {
    const id = Number(params.id);
    if (!Number.isInteger(id)) {
      return err("Invalid task id", 400);
    }

    const task = getTaskById(id);
    if (!task) {
      return err("Task not found", 404);
    }

    return ok(task);
  });
}

export async function action({ request, params }: ActionFunctionArgs) {
  const id = Number(params.id);

  return withLogging(`${request.method} /api/tasks/:id`, async () => {
    if (!Number.isInteger(id)) {
      return err("Invalid task id", 400);
    }

    if (request.method === "DELETE") {
      const deleted = deleteTask(id);
      return deleted ? ok(null, 204) : err("Task not found", 404);
    }

    try {
      const body = await request.json();

      if (request.method === "PUT") {
        const errors = validateTaskPayload(body, { requireAll: true });
        if (errors.length > 0) return err(errors, 400);

        const updated = replaceTask(id, {
          title: body.title,
          description: body.description,
          completed: body.completed,
          dueDate: body.dueDate
        });

        return updated ? ok(updated) : err("Task not found", 404);
      }

      if (request.method === "PATCH") {
        const errors = validateTaskPayload(body, { requireAll: false });
        if (errors.length > 0) return err(errors, 400);

        const updated = patchTask(id, {
          title: body.title,
          description: body.description,
          completed: body.completed,
          dueDate: body.dueDate
        });

        return updated ? ok(updated) : err("Task not found", 404);
      }

      return err("Method Not Allowed", 405);
    } catch {
      return err("Invalid JSON body", 400);
    }
  });
}

Detailed Breakdown

The loader function handles GET requests for individual tasks. It extracts the id from the URL, checks that it’s an integer, and then uses getTaskById() to fetch the data. If no task is found, a 404 Not Found response is returned.

The action function processes write operations—DELETE, PUT, and PATCH—all within a single function. Each branch checks the request’s method and applies the correct logic.

  • DELETE removes a task by calling deleteTask(). If the task exists, it returns a 204 No Content response.
  • PUT completely replaces a task’s data after full validation. It uses replaceTask() and maintains the original creation timestamp.
  • PATCH performs a partial update, only modifying the provided fields. It validates input with requireAll: false and updates the record via patchTask().

Robust error handling guards against invalid IDs, non-existent tasks, and bad JSON bodies—ensuring the API fails gracefully and predictably.

Example GET (not found):

{
  "error": "Task not found"
}

Example PATCH (success):

{
  "data": {
    "id": 1,
    "title": "Update course lesson",
    "description": "Expand service layer explanations",
    "completed": false,
    "createdAt": "2024-06-01T12:00:00.000Z",
    "updatedAt": "2024-06-02T09:30:00.000Z"
  }
}

Handling Errors and Validating Input

Your routes follow a consistent pattern for validation and error handling that keeps your API reliable and easy to work with.

  • Centralized validation: The validateTaskPayload() function ensures every incoming payload is well-structured and type-safe. It blocks malformed data before it reaches your logic layer.
  • Standardized responses: The ok() and err() helpers guarantee that all responses share a consistent format, which is vital for frontend integration.
  • Automatic request logging: Wrapping every handler with withLogging() ensures that every request is tracked with timestamps and method details—making debugging and monitoring far easier.

Together, these tools ensure your routes are consistent, readable, and production-ready.

Summary and What’s Next

In this lesson, you:

  • Connected your service layer to Remix API routes.
  • Built collection routes for getting and creating tasks.
  • Created item routes for fetching, updating, and deleting tasks.
  • Implemented validation, logging, and error handling to standardize behavior.

With these routes in place, your Task Manager API is now functional and maintainable. In the next practice, you’ll test your routes using the provided UI and confirm everything works—cementing your understanding of Remix backend architecture and service integration.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal