Build a Team Task Manager API

Build a Laravel 13 REST API and Next.js 16 frontend for team task management.

Introduction

30 Second Summary

Every team needs a way to track who's doing what. Whether it's sticky notes on a wall or a project board, task management is the backbone of getting things done together.

In this project, you will build a full stack Team Task Manager with a Laravel 13 REST API on the backend and a Next.js 16 frontend. You'll create, assign, and track tasks across team members using modern tools that match real-world job requirements.

What You'll Build

A Team Task Manager where a Laravel 13 API serves task data using the new JSON:API Resources feature, and a Next.js 16 App Router frontend displays and creates tasks with Server Components and Client Components.

By the end of this project, you'll have:

  • A Laravel 13 REST API with three related models (Team, Member, Task) and JSON:API Resource responses.
  • Database migrations and seeders that generate instant demo data for your app.
  • A Next.js 16 dashboard that displays tasks grouped by status and lets you create new ones through an interactive form.
  • Secret Mission: Add task filtering by status and a search bar with debounced input for a polished user experience.

Are there any prerequisites?

This project assumes basic familiarity with Laravel (routing, models) and Next.js (components, pages). You don't need to be an expert. Step 1 walks you through installing PHP and Composer if you don't already have them.

Install PHP and Composer

Before building your Laravel API, you need two tools installed on your machine. PHP is the programming language that Laravel is built on.

You also need Composer, PHP's package manager. Composer downloads and organizes all the libraries your Laravel project depends on.

In this step, get ready to:
  • Install PHP 8.3 using Chocolatey.
  • Install Composer using the Windows installer.
  • Verify both tools work from your terminal.
Install PHP 8.3 with Chocolatey

Chocolatey is a package manager for Windows that installs software from the command line. You need it to install PHP quickly without manually downloading ZIP files and editing PATH variables.

  • Press the Windows key to open your search bar.
  • Type PowerShell.
  • Right-click Windows PowerShell and select Run as administrator.
  • Click Yes if you see a User Account Control prompt.

First, check whether Chocolatey is already installed by running this command:

choco --version

✔️ I see a version number

Chocolatey is already set up. Skip ahead to the PHP install command below.

ⓧ Command not found

You need to install Chocolatey first. Make sure you are in an Administrator PowerShell window.

  • Install Chocolatey by running this command:
Set-ExecutionPolicy Bypass -Scope Process -Force
irm https://community.chocolatey.org/install.ps1 | iex

What does this command do?

The first line temporarily allows PowerShell to run downloaded scripts. The second line downloads the Chocolatey install script from the official site and runs it.

  • After the installation completes, close PowerShell completely.
  • Reopen PowerShell as Administrator (same steps as before).
  • Confirm Chocolatey is ready by running:
choco --version

You should see a version number printed in the terminal.

Now that Chocolatey is ready, you can install PHP.

  • Install PHP 8.3 by running this command:
choco install php --version=8.3.30 -y

What does this command do?

This tells Chocolatey to download and install PHP version 8.3.30. Laravel 13 requires PHP 8.3 or higher.

The -y flag auto-confirms the installation so you do not have to type "yes" manually. Chocolatey handles downloading the correct files and adding PHP to your system PATH.

You should see output ending with a message that says the install of php was successful.

Seeing an error during PHP installation?

Make sure you are running PowerShell as Administrator. Chocolatey requires admin privileges to install packages.

If you see a network error, check your internet connection and try the command again.

Help me troubleshoot my PHP installation with Chocolatey.

Install Composer with the Windows installer

Composer has a dedicated Windows installer that detects your PHP installation and configures everything automatically.

  • Open your browser and go to the Composer download page.
  • Click the Composer-Setup.exe link under the Windows Installer section to download it.
  • Double-click the downloaded Composer-Setup.exe file to run it.

What will the installer ask?

The installer will ask you to select your PHP executable. It should auto-detect the PHP you just installed via Chocolatey. Click Next to accept the detected path.

  • Click Next to accept the detected PHP path.
  • Continue clicking Next through the remaining screens.
  • Click Install.
  • Click Finish to close the installer.

What did the installer do?

The Composer installer added the composer command to your system PATH. This means you can run Composer from any terminal window without specifying its full file path.

Installer can't find PHP?

If the installer does not auto-detect PHP, click Browse and navigate to the folder where Chocolatey installed PHP. This is typically C:\tools\php83. Select php.exe inside that folder.

Help me find where Chocolatey installed PHP on my machine.

Verify both installations

Both tools should now be on your system PATH. Open a fresh terminal to confirm they are available.

  • Close any open PowerShell or terminal windows.
  • Press the Windows key, type PowerShell, and press Enter to open a new window.

Why a fresh terminal?

PATH changes only take effect when a new terminal session starts. Any terminal you had open during installation will not know about the new commands.

  • Check PHP by running this command:
php -v

✔️ I see a version number

You should see output starting with PHP 8.3.30 (or a similar 8.3.x version). PHP is ready.

ⓧ Command not found

  • Restart your computer. Some PATH changes only take effect after a full reboot.
  • After restarting, open a new PowerShell window and try php -v again.
  • If it still does not work, open PowerShell as Administrator and run the install command again:
choco install php --version=8.3.30 -y

PHP is not recognized after installing with Chocolatey. Help me fix this.

  • Check Composer by running this command:
composer -V

✔️ I see a version number

You should see output starting with Composer version 2.9 (or similar). Composer is ready.

ⓧ Command not found

  • Restart your computer and try again in a fresh PowerShell window.
  • If it still does not work, re-run the Composer-Setup.exe installer. Make sure it detects your PHP installation on the first screen.

Composer is not recognized in my terminal. Help me fix this.

Both tools are ready. Next up, you will use Composer to create your Laravel 13 project.

Create the Laravel 13 Project

PHP and Composer are installed and ready to go. Now it's time to create the actual Laravel project.

Composer can scaffold a full Laravel 13 application with a single command. In this step, you'll create the project, configure it to use SQLite as the database, and run your first migration to verify everything works.

In this step, get ready to:
  • Create a new Laravel 13 project using Composer.
  • Configure SQLite as the database.
  • Run the initial migration to verify everything works.
Create a new Laravel 13 project

Composer can download the entire Laravel framework and set up a project folder with a single command. This gives you routing, database tools, configuration files, and the Artisan CLI out of the box.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Laravel 13 project by running:
composer create-project laravel/laravel task-manager-api

What does this command do?

composer create-project downloads the Laravel framework and all its dependencies into a new folder called task-manager-api.

This includes routing, database tools, authentication scaffolding, and the Artisan command-line tool.

The installer may ask questions

If prompted about creating a SQLite database, type yes and press Enter. If prompted about running migrations, type no. You'll run them yourself in a later substep.

The installation takes a minute or two while Composer downloads all the packages.

  • Once the installation finishes, move into your new project folder by running:
cd task-manager-api
  • Confirm the project was created by running:
ls

You should see Laravel's folder structure including app, config, database, routes, and a file called artisan.

Don't see these files?

Make sure you ran cd task-manager-api after the installation finished.

If the composer create-project command failed, check that Composer is installed by running composer --version.

Help me troubleshoot why my Laravel project didn't install correctly.

Configure SQLite as the database

Every Laravel project needs a database to store its data. Instead of installing a full database server like MySQL, you'll use SQLite.

What is SQLite?

SQLite is a lightweight database that stores everything in a single file. It ships with PHP, so there's nothing extra to install.

The database patterns you'll learn (migrations, relationships, queries) work identically with MySQL or PostgreSQL. SQLite just removes the setup overhead.

Laravel stores database settings in a file called .env. You need to verify that this file is configured for SQLite.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the task-manager-api folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click the .env file at the root of the project.
  • Find the line that says DB_CONNECTION=sqlite.

Laravel 13 defaults to SQLite, so this should already be set. If you see a different value like mysql, change it to sqlite.

What is the .env file?

The .env file stores environment-specific settings like database connections, API keys, and debug flags. Laravel reads this file on every request.

You never commit this file to version control because each environment (local, staging, production) has its own copy with different values.

Run the initial migration

Laravel uses migrations to define database tables in code. The framework ships with a few default migrations for user accounts and sessions.

Running these migrations verifies that your database connection is working correctly.

  • Run the initial migrations by running this command:
php artisan migrate

What does this command do?

php artisan migrate reads every migration file in database/migrations/ and creates the corresponding tables in your SQLite database.

You'll see output listing each migration as it runs, like create_users_table and create_sessions_table.

Prompted to create the database?

If you see a message asking whether to create the SQLite database, type yes and press Enter. Laravel creates the database/database.sqlite file for you automatically.

If the migrations ran without any prompt, the installer already created the file during project setup. Either way is fine.

Seeing a database error?

Open your .env file and confirm DB_CONNECTION=sqlite is set. If it says mysql or anything else, change it to sqlite and save the file.

Make sure your terminal is inside the task-manager-api folder. Run pwd to check your current directory.

Help me fix my Laravel migration error.

Now verify the full setup by starting Laravel's built-in development server.

  • Start the development server by running:
php artisan serve
  • Open your browser and go to http://localhost:8000.

You should see the Laravel welcome page. This confirms your project, database, and server are all working.

Don't see the welcome page?

Check your terminal for error messages. A common issue is port 8000 already being in use by another application.

If you see a database error, make sure the database/database.sqlite file exists and that .env has DB_CONNECTION=sqlite.

To stop the server later, press Ctrl+C in the terminal.

Help me troubleshoot my Laravel development server.

Your Laravel 13 project is up and running with a working database connection. Next up, you'll design the database schema for teams, members, and tasks.

Build the Database Schema

Your Laravel 13 project is set up and the database connection is working. Before you can build an API, you need data to serve.

A task manager needs three types of data: teams, members, and tasks. In this step, you'll define database tables for each one, connect them with relationships, and fill them with demo data.

In this step, get ready to:
  • Create migration files for the teams, members, and tasks tables.
  • Define Eloquent models with relationships connecting all three tables.
  • Seed the database with realistic demo data.
Create the migration files

In Laravel, migrations are version-controlled blueprints for your database tables. Each migration file describes the columns and constraints for a single table.

  • Generate the three migration files by running these commands in your terminal (make sure you are inside the task-manager-api folder):
php artisan make:migration create_teams_table
php artisan make:migration create_members_table
php artisan make:migration create_tasks_table

What do these commands do?

Each make:migration command creates a new PHP file inside database/migrations/. Laravel adds a timestamp to the filename so it knows the order to run them in.

Because the migration names include create_teams_table, Laravel pre-fills each file with a stub that already has the table name set.

  • Confirm the files were created by running:
ls database/migrations/

You should see three new files containing create_teams_table, create_members_table, and create_tasks_table in their names, alongside the existing Laravel migration files.

Now you'll fill in each migration with the actual column definitions. Start with the teams table.

  • Open the migration file that has create_teams_table in its name.
  • Replace the entire file contents with this code:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Create the teams table with a name column
        Schema::create('teams', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('teams');
    }
};

What does this migration do?

  • id() creates an auto-incrementing primary key.
  • string('name') adds a text column for the team name.
  • timestamps() adds created_at and updated_at columns that Laravel manages automatically.
  • down() defines how to reverse the migration by dropping the table.
  • Save the file.
  • Open the migration file that has create_members_table in its name.
  • Replace the entire file contents with this code:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Create the members table linked to teams via a foreign key
        Schema::create('members', function (Blueprint $table) {
            $table->id();
            $table->foreignId('team_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('members');
    }
};

What does this migration do?

  • foreignId('team_id')->constrained()->cascadeOnDelete() creates a foreign key linking each member to a team. If a team is deleted, all its members are automatically removed too.
  • unique() on the email column prevents duplicate email addresses in the database.
  • Save the file.
  • Open the migration file that has create_tasks_table in its name.
  • Replace the entire file contents with this code:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Create the tasks table with status, priority, and a link to the assigned member
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('member_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->text('description')->nullable();
            $table->enum('status', ['pending', 'in_progress', 'done'])->default('pending');
            $table->enum('priority', ['low', 'medium', 'high'])->default('medium');
            $table->date('due_date')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

What does this migration do?

  • foreignId('member_id') links each task to the member it is assigned to.
  • enum('status', [...]) restricts the status column to exactly three values: pending, in_progress, and done.
  • nullable() on description and due_date means these fields are optional when creating a task.
  • Save the file.

✔️ Awesome, I've got all three migrations!

Great. Double check you have saved all three migration files before moving on.

ⓧ I'd like to double check the full code

Here are the complete contents for each migration file. Your filenames will have a different timestamp prefix, but the content inside should match exactly.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('teams', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('teams');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('members', function (Blueprint $table) {
            $table->id();
            $table->foreignId('team_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('members');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('member_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->text('description')->nullable();
            $table->enum('status', ['pending', 'in_progress', 'done'])->default('pending');
            $table->enum('priority', ['low', 'medium', 'high'])->default('medium');
            $table->date('due_date')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};
Define the Eloquent models

Migrations define the database tables, but your Laravel code interacts with data through Eloquent models. Each model maps to a table and defines the relationships between tables.

  • Generate three model files by running:
php artisan make:model Team
php artisan make:model Member
php artisan make:model Task

What do these commands do?

Each command creates a PHP class inside app/Models/. The generated files extend Laravel's base Model class, giving you built-in methods for querying, creating, and updating records.

Now you'll replace each model stub with the actual relationship definitions. The task manager has this data flow: a Team has many Members, and each Member has many Tasks.

  • Open app/Models/Team.php.
  • Replace the entire file contents with this code:
<?php

namespace App\Models;

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

class Team extends Model
{
    // Allow mass-assignment on the name field
    protected $fillable = ['name'];

    // A team has many members
    public function members(): HasMany
    {
        return $this->hasMany(Member::class);
    }
}

What does this model do?

  • $fillable lists the columns you can set when creating or updating a record. Laravel blocks mass-assignment on any column not listed here.
  • members() defines a one-to-many relationship. Calling $team->members returns all members that belong to that team.
  • Save the file.
  • Open app/Models/Member.php.
  • Replace the entire file contents with this code:
<?php

namespace App\Models;

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

class Member extends Model
{
    // Allow mass-assignment on these fields
    protected $fillable = ['team_id', 'name', 'email'];

    // A member belongs to one team
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    // A member has many tasks assigned to them
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

What does this model do?

The Member model sits in the middle of your data structure. It uses belongsTo to point up to its parent Team, and hasMany to point down to its child Tasks.

This means you can traverse the data in both directions: $member->team gives you the team, and $member->tasks gives you all assigned tasks.

  • Save the file.
  • Open app/Models/Task.php.
  • Replace the entire file contents with this code:
<?php

namespace App\Models;

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

class Task extends Model
{
    // Allow mass-assignment on these fields
    protected $fillable = ['member_id', 'title', 'description', 'status', 'priority', 'due_date'];

    // A task belongs to one member
    public function member(): BelongsTo
    {
        return $this->belongsTo(Member::class);
    }
}

What does this model do?

The Task model is the leaf of your data tree. It only needs a belongsTo relationship pointing to the Member who owns it. Calling $task->member returns the assigned member.

  • Save the file.

✔️ Awesome, I've got all three models!

Great. Double check you have saved all three model files before moving on.

ⓧ I'd like to double check the full code

Here are the complete contents for each model file.

<?php

namespace App\Models;

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

class Team extends Model
{
    protected $fillable = ['name'];

    public function members(): HasMany
    {
        return $this->hasMany(Member::class);
    }
}
<?php

namespace App\Models;

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

class Member extends Model
{
    protected $fillable = ['team_id', 'name', 'email'];

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

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}
<?php

namespace App\Models;

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

class Task extends Model
{
    protected $fillable = ['member_id', 'title', 'description', 'status', 'priority', 'due_date'];

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

Seeing a class not found error later?

  • Check that the namespace at the top of each model says App\Models. A typo here prevents Laravel from finding the class.
  • Make sure the class names match the filenames exactly: Team in Team.php, Member in Member.php, Task in Task.php.

Why can't Laravel find my Eloquent model?

Run migrations and seed the database

The migrations and models are written, but the tables don't exist yet. You need to run the migrations to create the tables, then fill them with sample data using a seeder.

  • Apply all three migrations by running:
php artisan migrate

You should see output listing each migration file that was applied, with a green "DONE" next to each one. Three new tables should appear: teams, members, and tasks.

Seeing a migration error?

  • If you see table already exists, your migrations may have run partially. Run php artisan migrate:fresh to drop all tables and re-run every migration from scratch.
  • If you see a syntax error, open the migration file mentioned in the error and compare it to the code above. A missing semicolon or bracket is the most common cause.
  • Make sure you are running the command from inside the task-manager-api folder.

Why is my Laravel migration failing?

The tables exist but they are empty. Laravel ships with a DatabaseSeeder.php file that you can use to populate the database with demo data.

  • Open database/seeders/DatabaseSeeder.php.
  • Replace the entire file contents with this code:
<?php

namespace Database\Seeders;

use App\Models\Team;
use App\Models\Member;
use App\Models\Task;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Create one team
        $team = Team::create(['name' => 'Engineering']);

        // Create three team members
        $alice = Member::create([
            'team_id' => $team->id,
            'name' => 'Alice Martin',
            'email' => 'alice@example.com',
        ]);

        $bob = Member::create([
            'team_id' => $team->id,
            'name' => 'Bob Dupont',
            'email' => 'bob@example.com',
        ]);

        $sara = Member::create([
            'team_id' => $team->id,
            'name' => 'Sara Benali',
            'email' => 'sara@example.com',
        ]);

        // Create five tasks with different statuses and priorities
        Task::create([
            'member_id' => $alice->id,
            'title' => 'Set up CI/CD pipeline',
            'description' => 'Configure GitHub Actions for automated testing and deployment.',
            'status' => 'in_progress',
            'priority' => 'high',
            'due_date' => now()->addDays(3)->toDateString(),
        ]);

        Task::create([
            'member_id' => $bob->id,
            'title' => 'Design database schema',
            'description' => 'Create ERD and define relationships for the new feature.',
            'status' => 'done',
            'priority' => 'high',
            'due_date' => now()->subDays(1)->toDateString(),
        ]);

        Task::create([
            'member_id' => $sara->id,
            'title' => 'Write API documentation',
            'description' => 'Document all endpoints with request/response examples.',
            'status' => 'pending',
            'priority' => 'medium',
            'due_date' => now()->addDays(7)->toDateString(),
        ]);

        Task::create([
            'member_id' => $alice->id,
            'title' => 'Code review for auth module',
            'description' => 'Review pull request #42 for security best practices.',
            'status' => 'pending',
            'priority' => 'low',
            'due_date' => now()->addDays(2)->toDateString(),
        ]);

        Task::create([
            'member_id' => $bob->id,
            'title' => 'Optimize database queries',
            'description' => 'Add indexes and refactor N+1 queries in the dashboard.',
            'status' => 'in_progress',
            'priority' => 'medium',
            'due_date' => now()->addDays(5)->toDateString(),
        ]);
    }
}

What does the seeder do?

The seeder creates a realistic dataset: 1 Engineering team, 3 members (Alice, Bob, Sara), and 5 tasks spread across all three statuses (pending, in_progress, done).

Notice how it uses the $fillable fields you defined on each model. The now()->addDays(3)->toDateString() calls generate dates relative to today, so the demo data always looks current.

  • Save DatabaseSeeder.php.
  • Run the seeder by executing:
php artisan db:seed

You should see output confirming that the DatabaseSeeder ran successfully.

  • Verify the data was inserted by running:
php artisan tinker --execute="echo App\Models\Task::count() . ' tasks, ' . App\Models\Member::count() . ' members, ' . App\Models\Team::count() . ' team';"

You should see 5 tasks, 3 members, 1 team printed in the terminal.

Seeing 0 tasks or a seeder error?

  • If the seeder throws a "table not found" error, make sure you ran php artisan migrate before php artisan db:seed.
  • If you see a duplicate entry error, your seeder may have run twice. Run php artisan migrate:fresh --seed to reset everything and re-seed in one step.
  • Check that the use statements at the top of DatabaseSeeder.php import all three models: Team, Member, and Task.

Why is my Laravel database seeder failing?

✔️ Awesome, I see the correct counts!

Great. Your database is fully set up with tables, relationships, and demo data.

ⓧ I'd like to double check the seeder code

<?php

namespace Database\Seeders;

use App\Models\Team;
use App\Models\Member;
use App\Models\Task;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $team = Team::create(['name' => 'Engineering']);

        $alice = Member::create([
            'team_id' => $team->id,
            'name' => 'Alice Martin',
            'email' => 'alice@example.com',
        ]);

        $bob = Member::create([
            'team_id' => $team->id,
            'name' => 'Bob Dupont',
            'email' => 'bob@example.com',
        ]);

        $sara = Member::create([
            'team_id' => $team->id,
            'name' => 'Sara Benali',
            'email' => 'sara@example.com',
        ]);

        Task::create([
            'member_id' => $alice->id,
            'title' => 'Set up CI/CD pipeline',
            'description' => 'Configure GitHub Actions for automated testing and deployment.',
            'status' => 'in_progress',
            'priority' => 'high',
            'due_date' => now()->addDays(3)->toDateString(),
        ]);

        Task::create([
            'member_id' => $bob->id,
            'title' => 'Design database schema',
            'description' => 'Create ERD and define relationships for the new feature.',
            'status' => 'done',
            'priority' => 'high',
            'due_date' => now()->subDays(1)->toDateString(),
        ]);

        Task::create([
            'member_id' => $sara->id,
            'title' => 'Write API documentation',
            'description' => 'Document all endpoints with request/response examples.',
            'status' => 'pending',
            'priority' => 'medium',
            'due_date' => now()->addDays(7)->toDateString(),
        ]);

        Task::create([
            'member_id' => $alice->id,
            'title' => 'Code review for auth module',
            'description' => 'Review pull request #42 for security best practices.',
            'status' => 'pending',
            'priority' => 'low',
            'due_date' => now()->addDays(2)->toDateString(),
        ]);

        Task::create([
            'member_id' => $bob->id,
            'title' => 'Optimize database queries',
            'description' => 'Add indexes and refactor N+1 queries in the dashboard.',
            'status' => 'in_progress',
            'priority' => 'medium',
            'due_date' => now()->addDays(5)->toDateString(),
        ]);
    }
}

Your database is loaded with realistic demo data. Next up, you'll build the API layer that serves this data as JSON:API compliant responses.

Build the JSON:API Resource Layer

Your database has three related models and five seed tasks ready to go. The missing piece is a way for the Next.js frontend to request that data over HTTP.

In this step, you'll build the REST API layer. Laravel 13 introduces JSON:API Resources, a built-in feature that formats responses according to the JSON:API specification. You'll generate resource classes, create controllers, and register routes.

In this step, get ready to:
  • Generate JSON:API Resource classes for your three models.
  • Define the attributes and relationships each resource exposes.
  • Create API controllers and routes to serve JSON:API responses.
Generate JSON:API Resource and controller classes

Before writing any code by hand, you can use Laravel's Artisan CLI to scaffold the files you need. The --json-api flag tells Artisan to generate a resource class that extends JsonApiResource instead of the standard JsonResource.

Laravel 13 does not include routes/api.php by default. You first need to enable API routing with the install:api command, which creates the file and installs Laravel Sanctum.

  • Make sure your terminal is inside the task-manager-api folder. If you are not there yet, navigate to it by running:
cd ~/Desktop/task-manager-api
  • Enable API routing by running this command:
php artisan install:api

What will this command ask?

The install:api command installs Laravel Sanctum for API token authentication and creates routes/api.php. It may prompt you to run pending migrations.

If it does, enter yes to let it create the personal_access_tokens table Sanctum needs.

  • Generate the three JSON:API Resource classes by running these commands:
php artisan make:resource TaskResource --json-api
php artisan make:resource MemberResource --json-api
php artisan make:resource TeamResource --json-api

What does this create?

Each command creates a new file inside app/Http/Resources/. The --json-api flag makes the class extend JsonApiResource, which automatically formats your response with a type, id, attributes, and relationships structure.

  • Generate the two API controller classes by running these commands:
php artisan make:controller Api/TaskController
php artisan make:controller Api/MemberController
  • Confirm all the generated files exist by running:
ls app/Http/Resources/
ls app/Http/Controllers/Api/

You should see TaskResource.php, MemberResource.php, and TeamResource.php in the Resources folder.

You should see TaskController.php and MemberController.php in the Controllers/Api folder.

Command not found?

Make sure you are running these commands from inside the task-manager-api folder. The artisan script lives at the root of your Laravel project.

Help me debug this artisan error

Define attributes and relationships on each resource

The generated resource files have empty $attributes and $relationships arrays. You need to fill these in so the API knows which model fields to expose and how models link together.

  • Open app/Http/Resources/TaskResource.php in your code editor and replace its entire contents with the following code:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class TaskResource extends JsonApiResource
{
    // Fields returned in the "attributes" object of each task
    public $attributes = [
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'created_at',
    ];

    // Related resources that can be included via ?include=member
    public $relationships = [
        'member' => MemberResource::class,
    ];
}

What does this code do?

The $attributes array lists which columns from the Task model appear in the response. Fields you leave out (like member_id) are hidden from the API consumer.

The $relationships array maps the member relationship to MemberResource. When a client adds ?include=member to the request URL, the response includes full member data alongside each task.

  • Save TaskResource.php.
  • Open app/Http/Resources/MemberResource.php and replace its entire contents with the following code:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class MemberResource extends JsonApiResource
{
    // Fields returned in the "attributes" object of each member
    public $attributes = [
        'name',
        'email',
    ];

    // Related resources: tasks is a simple reference, team maps to TeamResource
    public $relationships = [
        'tasks',
        'team' => TeamResource::class,
    ];
}

What does this code do?

The tasks entry in $relationships is a plain string without a class mapping. This tells Laravel to include relationship linkage data (the IDs) but lets you omit a dedicated resource class for the reverse side.

The team entry maps to TeamResource for full resource serialization when the client requests it.

  • Save MemberResource.php.
  • Open app/Http/Resources/TeamResource.php and replace its entire contents with the following code:
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class TeamResource extends JsonApiResource
{
    // Fields returned in the "attributes" object of each team
    public $attributes = [
        'name',
    ];

    // Related resources: members is a simple reference
    public $relationships = [
        'members',
    ];
}

What does this code do?

TeamResource exposes just the team name attribute. The members relationship is listed as a plain string, so it provides linkage data without needing full resource serialization on this side.

  • Save TeamResource.php.

Why JSON:API instead of standard Resources?

Standard Laravel Resources let you return any JSON shape you want. JSON:API enforces a consistent structure across every endpoint: a type, id, attributes, and relationships object in every response.

This consistency makes the API predictable for frontend developers. They always know where to find the data, regardless of which endpoint they call.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three resource files before continuing.

ⓧ I'd like to double check the full code

Here is the full code for each resource file.

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class TaskResource extends JsonApiResource
{
    public $attributes = [
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'created_at',
    ];

    public $relationships = [
        'member' => MemberResource::class,
    ];
}
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class MemberResource extends JsonApiResource
{
    public $attributes = [
        'name',
        'email',
    ];

    public $relationships = [
        'tasks',
        'team' => TeamResource::class,
    ];
}
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;

class TeamResource extends JsonApiResource
{
    public $attributes = [
        'name',
    ];

    public $relationships = [
        'members',
    ];
}
Create API controllers and routes

The resource classes define how data is formatted. Now you need controllers that fetch data from the database and routes that map URLs to those controllers.

  • Open app/Http/Controllers/Api/TaskController.php and replace its entire contents with the following code:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    // GET /api/tasks - return all tasks with their assigned member
    public function index()
    {
        $tasks = Task::with('member')->latest()->get();

        return TaskResource::collection($tasks);
    }

    // POST /api/tasks - validate input and create a new task
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'member_id' => 'required|exists:members,id',
            'status' => 'in:pending,in_progress,done',
            'priority' => 'in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        $task = Task::create($validated);
        $task->load('member');

        return new TaskResource($task);
    }
}

What does this code do?

  • The index() method fetches all tasks, eager-loads the member relationship to avoid N+1 queries, sorts by newest first, and wraps the results in TaskResource::collection().
  • The store() method validates the incoming request, creates a new task, loads the member relationship, and returns the new task as a single TaskResource.
  • Laravel's validate() automatically returns a 422 error if validation fails, so the controller stays clean.
  • Save TaskController.php.
  • Open app/Http/Controllers/Api/MemberController.php and replace its entire contents with the following code:
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\MemberResource;
use App\Models\Member;

class MemberController extends Controller
{
    // GET /api/members - return all members with their team
    public function index()
    {
        $members = Member::with('team')->get();

        return MemberResource::collection($members);
    }
}

What does this code do?

The index() method returns all members with their team relationship eager-loaded. The frontend needs this endpoint to populate the "Assign To" dropdown when creating tasks.

  • Save MemberController.php.
  • Open routes/api.php and replace its entire contents with the following code:
<?php

use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\MemberController;
use Illuminate\Support\Facades\Route;

// Register task routes: GET /api/tasks and POST /api/tasks
Route::apiResource('tasks', TaskController::class)->only(['index', 'store']);

// Register member routes: GET /api/members
Route::apiResource('members', MemberController::class)->only(['index']);

What does this code do?

The apiResource() method registers RESTful routes for a controller. The only() call limits which routes are created. For tasks, you get GET /api/tasks and POST /api/tasks. For members, you get GET /api/members.

Laravel automatically prefixes all routes in routes/api.php with /api, so you only write tasks and members here.

  • Save routes/api.php.
  • Start the Laravel dev server by running:
php artisan serve
  • Open your browser and visit http://localhost:8000/api/tasks.

You should see a JSON response with a data array containing your five seed tasks. Each task has a type, id, attributes, and relationships object.

Seeing a 404 or empty response?

  • Make sure you ran php artisan install:api earlier in this step. Without it, routes/api.php does not exist and Laravel cannot find the /api/tasks route.
  • Check that routes/api.php has the correct use statements at the top. A missing import causes a class-not-found error.
  • Make sure the Laravel dev server is running (php artisan serve) and you have not closed the terminal window.

Help me debug my API endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved all controller files and routes/api.php before continuing.

ⓧ I'd like to double check the full code

Here is the full code for each controller and the routes file.

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index()
    {
        $tasks = Task::with('member')->latest()->get();

        return TaskResource::collection($tasks);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'member_id' => 'required|exists:members,id',
            'status' => 'in:pending,in_progress,done',
            'priority' => 'in:low,medium,high',
            'due_date' => 'nullable|date',
        ]);

        $task = Task::create($validated);
        $task->load('member');

        return new TaskResource($task);
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\MemberResource;
use App\Models\Member;

class MemberController extends Controller
{
    public function index()
    {
        $members = Member::with('team')->get();

        return MemberResource::collection($members);
    }
}
<?php

use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\MemberController;
use Illuminate\Support\Facades\Route;

Route::apiResource('tasks', TaskController::class)->only(['index', 'store']);
Route::apiResource('members', MemberController::class)->only(['index']);

Your API is live and returning structured JSON:API responses. Next up, you'll build the Next.js frontend that consumes these endpoints and displays tasks on a dashboard.

Create the Next.js 16 Frontend

Your Laravel API is returning clean, JSON:API compliant responses for tasks, members, and teams. The backend is ready to serve data.

Now you need a frontend that consumes this API and displays it in a usable format. You'll use Next.js 16 with Server Components to fetch data on the server and Client Components to handle interactivity.

In this step, get ready to:
  • Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
  • Create a typed API layer that fetches data from your Laravel backend.
  • Build a task dashboard displaying tasks in three status columns.
Scaffold the Next.js 16 project

Your Laravel dev server is still running in its terminal window. You need a second terminal for the frontend project.

  • Open a new terminal window or tab. Keep the Laravel server running in the original one.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Next.js project by running this command:
npx create-next-app@latest task-manager-ui --ts --tailwind --eslint --app --src-dir --use-npm

What does this command do?

The --ts flag enables TypeScript. The --tailwind flag sets up Tailwind CSS for styling.

The --app flag uses the App Router. The --src-dir flag places your code inside a src/ directory to separate it from config files.

  • The installer may ask additional prompts. Accept the defaults by pressing Enter for each one.
  • Once installation completes, move into the project folder by running:
cd task-manager-ui
  • Confirm the project was created by running:
ls

You should see files like package.json, tsconfig.json, and a src/ folder.

Command not found or installation failed?

  • Make sure Node.js is installed by running node --version. You need version 20.9 or higher.
  • If npx is not found, reinstall Node.js from the official website.
  • Help me troubleshoot create-next-app

The frontend needs to know where the Laravel API lives. You'll store this in an environment variable.

  • Create a .env.local file in the root of task-manager-ui by running:
echo NEXT_PUBLIC_API_URL=http://localhost:8000/api > .env.local

Why use an environment variable?

The NEXT_PUBLIC_ prefix tells Next.js to expose this variable to both server and client code. Storing the API URL here means you can change it for production without editing any source files.

  • Start the Next.js dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the default Next.js welcome page.

Don't see the welcome page?

  • Make sure port 3000 is not already in use by another application.
  • Check that the npm run dev output shows Local: http://localhost:3000 without errors.
  • Help me debug my Next.js dev server
Create the data fetching layer

Before building any UI, you need a typed layer that knows how to talk to your Laravel API. This keeps all fetch logic in one place and gives TypeScript full visibility into the shape of your data.

  • Stop the dev server for now by pressing Ctrl+C in the terminal.
  • Create a new folder called lib inside the src directory of task-manager-ui.
  • Create a new file at src/lib/api.ts in your code editor.
  • Add the TypeScript interfaces and API base URL by copying this code into src/lib/api.ts:
// Shape of a single task's attributes from the JSON:API response
export interface TaskAttributes {
  title: string;
  description: string | null;
  status: 'pending' | 'in_progress' | 'done';
  priority: 'low' | 'medium' | 'high';
  due_date: string | null;
  created_at: string;
}

// Shape of a single member's attributes from the JSON:API response
export interface MemberAttributes {
  name: string;
  email: string;
}

// Generic wrapper for any JSON:API resource
export interface JsonApiResource<T> {
  id: string;
  type: string;
  attributes: T;
  relationships?: Record<string, { data: { id: string; type: string } | { id: string; type: string }[] }>;
}

// Top-level JSON:API response with optional included resources
export interface JsonApiResponse<T> {
  data: JsonApiResource<T>[];
  included?: JsonApiResource<unknown>[];
}

// Read the API URL from the environment variable
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';

What do these interfaces do?

Each interface mirrors the JSON:API response structure your Laravel API returns. TaskAttributes and MemberAttributes describe the data fields.

JsonApiResource wraps them with an id, type, and optional relationships. JsonApiResponse represents the top-level envelope with a data array and optional included array for related resources.

  • Add the fetch functions below the API_BASE line in the same file:
// Fetch all tasks with their assigned member included
export async function fetchTasks(): Promise<JsonApiResponse<TaskAttributes>> {
  const res = await fetch(`${API_BASE}/tasks?include=member`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch tasks');
  }

  return res.json();
}

// Fetch all team members
export async function fetchMembers(): Promise<JsonApiResponse<MemberAttributes>> {
  const res = await fetch(`${API_BASE}/members`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch members');
  }

  return res.json();
}

// Create a new task by POSTing to the API
export async function createTask(data: {
  title: string;
  description: string;
  member_id: number;
  priority: string;
}): Promise<void> {
  const res = await fetch(`${API_BASE}/tasks`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/vnd.api+json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    throw new Error('Failed to create task');
  }
}

What do these functions do?

fetchTasks calls GET /api/tasks?include=member so the response includes each task's assigned member data. The cache: 'no-store' option tells Next.js to always fetch fresh data instead of caching.

fetchMembers gets all team members. createTask sends a POST request with the application/vnd.api+json accept header, which is the standard JSON:API content type. You'll wire up createTask to a form in the next step.

  • Save src/lib/api.ts.

You won't see a change yet. The next substep builds the UI components that use these functions.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

export interface TaskAttributes {
  title: string;
  description: string | null;
  status: 'pending' | 'in_progress' | 'done';
  priority: 'low' | 'medium' | 'high';
  due_date: string | null;
  created_at: string;
}

export interface MemberAttributes {
  name: string;
  email: string;
}

export interface JsonApiResource<T> {
  id: string;
  type: string;
  attributes: T;
  relationships?: Record<string, { data: { id: string; type: string } | { id: string; type: string }[] }>;
}

export interface JsonApiResponse<T> {
  data: JsonApiResource<T>[];
  included?: JsonApiResource<unknown>[];
}

const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';

export async function fetchTasks(): Promise<JsonApiResponse<TaskAttributes>> {
  const res = await fetch(`${API_BASE}/tasks?include=member`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch tasks');
  }

  return res.json();
}

export async function fetchMembers(): Promise<JsonApiResponse<MemberAttributes>> {
  const res = await fetch(`${API_BASE}/members`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch members');
  }

  return res.json();
}

export async function createTask(data: {
  title: string;
  description: string;
  member_id: number;
  priority: string;
}): Promise<void> {
  const res = await fetch(`${API_BASE}/tasks`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/vnd.api+json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    throw new Error('Failed to create task');
  }
}
Build the task dashboard page

With the API layer ready, you can build the components that display your tasks. You'll create a card component for individual tasks, a dashboard that groups them by status, and a home page that ties everything together.

  • Create a components folder inside the src directory of task-manager-ui.
  • Create a new file at src/components/TaskCard.tsx and add this code:
import { JsonApiResource, TaskAttributes } from '@/lib/api';

// Props: a single task resource and the resolved member name
interface TaskCardProps {
  task: JsonApiResource<TaskAttributes>;
  memberName: string;
}

export default function TaskCard({ task, memberName }: TaskCardProps) {
  // Map priority values to Tailwind color classes
  const priorityColors = {
    low: 'bg-gray-100 text-gray-700',
    medium: 'bg-yellow-100 text-yellow-700',
    high: 'bg-red-100 text-red-700',
  };

  return (
    <div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
      <h3 className="font-medium text-gray-900 mb-1">{task.attributes.title}</h3>
      {task.attributes.description && (
        <p className="text-sm text-gray-500 mb-2">{task.attributes.description}</p>
      )}
      <div className="flex items-center justify-between text-xs">
        <span className="text-gray-500">{memberName}</span>
        <span className={`px-2 py-0.5 rounded-full ${priorityColors[task.attributes.priority]}`}>
          {task.attributes.priority}
        </span>
      </div>
      {task.attributes.due_date && (
        <p className="text-xs text-gray-400 mt-2">Due: {task.attributes.due_date}</p>
      )}
    </div>
  );
}

What does this component do?

TaskCard renders a single task as a card. It shows the title, description, assigned member name, a color-coded priority badge, and the due date.

The priorityColors object maps each priority level to matching Tailwind CSS classes. High priority tasks get a red badge, medium gets yellow, and low gets gray.

  • Save src/components/TaskCard.tsx.

You won't see a change yet. The dashboard component uses this card next.

The dashboard component imports a CreateTaskForm component that you'll build in the next step. You need a placeholder file so TypeScript doesn't throw an error.

  • Create a new file at src/components/CreateTaskForm.tsx and add this placeholder code:
'use client';

import { JsonApiResource, MemberAttributes } from '@/lib/api';

// Placeholder component - you'll build the full form in the next step
interface CreateTaskFormProps {
  members: JsonApiResource<MemberAttributes>[];
  onSuccess: () => void;
}

export default function CreateTaskForm({ members, onSuccess }: CreateTaskFormProps) {
  return null;
}
  • Save src/components/CreateTaskForm.tsx.
  • Create a new file at src/components/TaskDashboard.tsx and add this code:
'use client';

import { useState } from 'react';
import { JsonApiResponse, TaskAttributes, MemberAttributes } from '@/lib/api';
import TaskCard from './TaskCard';
import CreateTaskForm from './CreateTaskForm';

// Props: the full JSON:API responses for tasks and members
interface TaskDashboardProps {
  tasks: JsonApiResponse<TaskAttributes>;
  members: JsonApiResponse<MemberAttributes>;
}

export default function TaskDashboard({ tasks, members }: TaskDashboardProps) {
  // Track whether the task creation form is visible
  const [isFormOpen, setIsFormOpen] = useState(false);

  // Group tasks into three arrays by status
  const tasksByStatus = {
    pending: tasks.data.filter((t) => t.attributes.status === 'pending'),
    in_progress: tasks.data.filter((t) => t.attributes.status === 'in_progress'),
    done: tasks.data.filter((t) => t.attributes.status === 'done'),
  };

  // Look up a task's assigned member name from the included resources
  const getMemberName = (task: (typeof tasks.data)[0]): string => {
    const memberId = task.relationships?.member?.data;
    if (!memberId || Array.isArray(memberId)) return 'Unassigned';
    const member = tasks.included?.find(
      (inc) => inc.id === memberId.id && inc.type === 'members'
    );
    return (member?.attributes as MemberAttributes)?.name || 'Unassigned';
  };

  return (
    <div>
      <button
        onClick={() => setIsFormOpen(!isFormOpen)}
        className="mb-6 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
      >
        {isFormOpen ? 'Cancel' : '+ New Task'}
      </button>

      {isFormOpen && (
        <CreateTaskForm
          members={members.data}
          onSuccess={() => {
            setIsFormOpen(false);
            window.location.reload();
          }}
        />
      )}

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div>
          <h2 className="text-lg font-semibold text-yellow-700 mb-3">
            Pending ({tasksByStatus.pending.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.pending.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>

        <div>
          <h2 className="text-lg font-semibold text-blue-700 mb-3">
            In Progress ({tasksByStatus.in_progress.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.in_progress.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>

        <div>
          <h2 className="text-lg font-semibold text-green-700 mb-3">
            Done ({tasksByStatus.done.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.done.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

What does this component do?

The 'use client' directive marks this as a Client Component because it uses useState for the form toggle. The tasksByStatus object groups tasks into three arrays by filtering on the status attribute.

The getMemberName helper looks up a task's assigned member from the JSON:API included array. It matches on id and type to find the correct member resource. The three columns render Pending, In Progress, and Done tasks using TaskCard.

  • Save src/components/TaskDashboard.tsx.

You won't see a change yet. The final piece is the home page that calls your API and passes data to the dashboard.

  • Open src/app/page.tsx in your code editor. This file already exists from the scaffolding.
  • Replace the entire contents of src/app/page.tsx with this code:
import { fetchTasks, fetchMembers } from '@/lib/api';
import TaskDashboard from '@/components/TaskDashboard';

// Async Server Component - fetches data on the server before rendering
export default async function Home() {
  // Fetch tasks and members in parallel for faster loading
  const [tasksResponse, membersResponse] = await Promise.all([
    fetchTasks(),
    fetchMembers(),
  ]);

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">Team Task Manager</h1>
        <p className="text-gray-600 mb-8">Manage and track your team's tasks in one place.</p>
        <TaskDashboard tasks={tasksResponse} members={membersResponse} />
      </div>
    </main>
  );
}

Why is this an async function?

This is a Server Component. It runs on the Node.js server, not in the browser. The async keyword lets you await the fetch calls directly inside the component. No useEffect or useState needed.

Promise.all runs both API calls at the same time instead of waiting for one to finish before starting the other. The fetched data is passed as props to TaskDashboard, which is a Client Component that handles the interactive parts.

  • Save src/app/page.tsx.
  • Make sure your Laravel dev server is still running on http://localhost:8000 in the other terminal.
  • Start the Next.js dev server again by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the Team Task Manager heading with three columns: Pending, In Progress, and Done. Each column displays the seeded tasks as white cards with titles, descriptions, member names, and priority badges.

Seeing a blank page or an error?

  • Check that your Laravel dev server is still running on port 8000 in the other terminal. The Next.js server fetches from it at build time.
  • Verify that .env.local contains exactly NEXT_PUBLIC_API_URL=http://localhost:8000/api with no extra spaces or quotes.
  • If you see a Failed to fetch tasks error, try visiting http://localhost:8000/api/tasks directly in your browser to confirm the API returns JSON.
  • After changing .env.local, restart the Next.js dev server with Ctrl+C then npm run dev. Environment variable changes require a restart.
  • Help me debug the connection between Next.js and Laravel

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

export interface TaskAttributes {
  title: string;
  description: string | null;
  status: 'pending' | 'in_progress' | 'done';
  priority: 'low' | 'medium' | 'high';
  due_date: string | null;
  created_at: string;
}

export interface MemberAttributes {
  name: string;
  email: string;
}

export interface JsonApiResource<T> {
  id: string;
  type: string;
  attributes: T;
  relationships?: Record<string, { data: { id: string; type: string } | { id: string; type: string }[] }>;
}

export interface JsonApiResponse<T> {
  data: JsonApiResource<T>[];
  included?: JsonApiResource<unknown>[];
}

const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';

export async function fetchTasks(): Promise<JsonApiResponse<TaskAttributes>> {
  const res = await fetch(`${API_BASE}/tasks?include=member`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch tasks');
  }

  return res.json();
}

export async function fetchMembers(): Promise<JsonApiResponse<MemberAttributes>> {
  const res = await fetch(`${API_BASE}/members`, {
    cache: 'no-store',
  });

  if (!res.ok) {
    throw new Error('Failed to fetch members');
  }

  return res.json();
}

export async function createTask(data: {
  title: string;
  description: string;
  member_id: number;
  priority: string;
}): Promise<void> {
  const res = await fetch(`${API_BASE}/tasks`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/vnd.api+json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    throw new Error('Failed to create task');
  }
}
import { JsonApiResource, TaskAttributes } from '@/lib/api';

interface TaskCardProps {
  task: JsonApiResource<TaskAttributes>;
  memberName: string;
}

export default function TaskCard({ task, memberName }: TaskCardProps) {
  const priorityColors = {
    low: 'bg-gray-100 text-gray-700',
    medium: 'bg-yellow-100 text-yellow-700',
    high: 'bg-red-100 text-red-700',
  };

  return (
    <div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
      <h3 className="font-medium text-gray-900 mb-1">{task.attributes.title}</h3>
      {task.attributes.description && (
        <p className="text-sm text-gray-500 mb-2">{task.attributes.description}</p>
      )}
      <div className="flex items-center justify-between text-xs">
        <span className="text-gray-500">{memberName}</span>
        <span className={`px-2 py-0.5 rounded-full ${priorityColors[task.attributes.priority]}`}>
          {task.attributes.priority}
        </span>
      </div>
      {task.attributes.due_date && (
        <p className="text-xs text-gray-400 mt-2">Due: {task.attributes.due_date}</p>
      )}
    </div>
  );
}
'use client';

import { useState } from 'react';
import { JsonApiResponse, TaskAttributes, MemberAttributes } from '@/lib/api';
import TaskCard from './TaskCard';
import CreateTaskForm from './CreateTaskForm';

interface TaskDashboardProps {
  tasks: JsonApiResponse<TaskAttributes>;
  members: JsonApiResponse<MemberAttributes>;
}

export default function TaskDashboard({ tasks, members }: TaskDashboardProps) {
  const [isFormOpen, setIsFormOpen] = useState(false);

  const tasksByStatus = {
    pending: tasks.data.filter((t) => t.attributes.status === 'pending'),
    in_progress: tasks.data.filter((t) => t.attributes.status === 'in_progress'),
    done: tasks.data.filter((t) => t.attributes.status === 'done'),
  };

  const getMemberName = (task: (typeof tasks.data)[0]): string => {
    const memberId = task.relationships?.member?.data;
    if (!memberId || Array.isArray(memberId)) return 'Unassigned';
    const member = tasks.included?.find(
      (inc) => inc.id === memberId.id && inc.type === 'members'
    );
    return (member?.attributes as MemberAttributes)?.name || 'Unassigned';
  };

  return (
    <div>
      <button
        onClick={() => setIsFormOpen(!isFormOpen)}
        className="mb-6 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
      >
        {isFormOpen ? 'Cancel' : '+ New Task'}
      </button>

      {isFormOpen && (
        <CreateTaskForm
          members={members.data}
          onSuccess={() => {
            setIsFormOpen(false);
            window.location.reload();
          }}
        />
      )}

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div>
          <h2 className="text-lg font-semibold text-yellow-700 mb-3">
            Pending ({tasksByStatus.pending.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.pending.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>

        <div>
          <h2 className="text-lg font-semibold text-blue-700 mb-3">
            In Progress ({tasksByStatus.in_progress.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.in_progress.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>

        <div>
          <h2 className="text-lg font-semibold text-green-700 mb-3">
            Done ({tasksByStatus.done.length})
          </h2>
          <div className="space-y-3">
            {tasksByStatus.done.map((task) => (
              <TaskCard key={task.id} task={task} memberName={getMemberName(task)} />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
import { fetchTasks, fetchMembers } from '@/lib/api';
import TaskDashboard from '@/components/TaskDashboard';

export default async function Home() {
  const [tasksResponse, membersResponse] = await Promise.all([
    fetchTasks(),
    fetchMembers(),
  ]);

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">Team Task Manager</h1>
        <p className="text-gray-600 mb-8">Manage and track your team's tasks in one place.</p>
        <TaskDashboard tasks={tasksResponse} members={membersResponse} />
      </div>
    </main>
  );
}

Your frontend is fetching real data from the Laravel API and displaying it in a clean dashboard. Next up, you'll build the task creation form so you can add new tasks directly from the browser.

Add Task Creation with Client Components

Your dashboard displays tasks beautifully, but it's read-only. A task manager that can't create tasks isn't much of a manager.

In Next.js, components that handle user interaction (clicks, form inputs, state changes) must be Client Components. You'll build a form component that collects task details, sends them to your Laravel API's POST endpoint, and refreshes the dashboard to show the new task.

In this step, get ready to:
  • Build an interactive task creation form as a Client Component.
  • Connect the form to the Laravel API's POST endpoint.
  • Test the full stack creation flow end-to-end.
Create the task creation form component

The TaskDashboard component you built in the last step already has a + New Task button and imports a CreateTaskForm component that doesn't exist yet. Clicking the button right now causes an error. Time to create that component.

What is a Client Component?

In Next.js, Server Components (the default) render on the server and can't use browser APIs like useState or event handlers like onClick.

Adding 'use client' at the top of a file tells Next.js to bundle that component for the browser. This enables hooks, form inputs, and interactive behavior. Your form needs all of these.

  • Create a new file at src/components/CreateTaskForm.tsx inside your task-manager-ui project.
  • Add the component's imports, interface, and state management by pasting the following code:
'use client';

import { useState } from 'react';
import { createTask, JsonApiResource, MemberAttributes } from '@/lib/api';

// Props: list of members for the dropdown, callback for successful creation
interface CreateTaskFormProps {
  members: JsonApiResource<MemberAttributes>[];
  onSuccess: () => void;
}

export default function CreateTaskForm({ members, onSuccess }: CreateTaskFormProps) {
  // Controlled state for each form field
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [memberId, setMemberId] = useState('');
  const [priority, setPriority] = useState('medium');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Send the new task to the Laravel API
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      await createTask({
        title,
        description,
        member_id: Number(memberId),
        priority,
      });
      onSuccess();
    } catch (error) {
      alert('Failed to create task. Please try again.');
    } finally {
      setIsSubmitting(false);
    }
  };

  // Form UI added below
}

What does this code do?

  • 'use client' marks this as a Client Component so React hooks and event handlers work in the browser.
  • Five useState hooks create controlled inputs. Each form field's value lives in React state, so the component always knows what the user typed.
  • handleSubmit calls the createTask function from api.ts, which POSTs to your Laravel API. On success, it calls onSuccess() to close the form and refresh the page.
  • Number(memberId) converts the string from the dropdown to an integer, which is what the Laravel validation expects.

The component still needs its form UI. This is the JSX that renders the input fields, dropdowns, and submit button.

  • Replace the // Form UI added below comment with the following return statement:
  return (
    <form onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 mb-6">
      <h3 className="text-lg font-semibold mb-4">Create New Task</h3>
      {/* Two-column grid for form fields */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Assign To</label>
          {/* Dropdown populated from the members prop */}
          <select
            value={memberId}
            onChange={(e) => setMemberId(e.target.value)}
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          >
            <option value="">Select a member</option>
            {members.map((member) => (
              <option key={member.id} value={member.id}>
                {member.attributes.name}
              </option>
            ))}
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
          <select
            value={priority}
            onChange={(e) => setPriority(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          >
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
          <input
            type="text"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
      </div>
      {/* Submit button with loading state */}
      <button
        type="submit"
        disabled={isSubmitting}
        className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
      >
        {isSubmitting ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );

What does this JSX do?

  • The <form onSubmit={handleSubmit}> wrapper calls your submission logic when the user clicks the button or presses Enter.
  • The Assign To dropdown maps over the members prop to render each team member as a selectable option. These members come from the /api/members endpoint you built earlier.
  • The submit button shows "Creating..." and becomes disabled while the request is in flight. This prevents duplicate submissions.
  • Save CreateTaskForm.tsx.
  • Go to your browser at http://localhost:3000 and click the + New Task button.

You should see a white form card appear above the status columns with fields for Title, Assign To, Priority, and Description.

Don't see the form?

  • Check your terminal for TypeScript errors. A missing import or typo in the interface will prevent the page from compiling.
  • Make sure the file is saved at src/components/CreateTaskForm.tsx (not src/app/components/). The import in TaskDashboard.tsx expects this exact path.
  • Verify the 'use client' directive is on the very first line of the file. It must come before all imports.

Why isn't my CreateTaskForm rendering when I click the button?

How is the form already wired up?

In the previous step, TaskDashboard.tsx was built with an import for CreateTaskForm and conditional rendering tied to the isFormOpen state. It passes members.data for the dropdown and an onSuccess callback that closes the form and reloads the page.

By creating the CreateTaskForm file, you filled in the missing piece. Everything connects automatically.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete CreateTaskForm.tsx file. Compare it with yours:

'use client';

import { useState } from 'react';
import { createTask, JsonApiResource, MemberAttributes } from '@/lib/api';

interface CreateTaskFormProps {
  members: JsonApiResource<MemberAttributes>[];
  onSuccess: () => void;
}

export default function CreateTaskForm({ members, onSuccess }: CreateTaskFormProps) {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [memberId, setMemberId] = useState('');
  const [priority, setPriority] = useState('medium');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      await createTask({
        title,
        description,
        member_id: Number(memberId),
        priority,
      });
      onSuccess();
    } catch (error) {
      alert('Failed to create task. Please try again.');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 mb-6">
      <h3 className="text-lg font-semibold mb-4">Create New Task</h3>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Assign To</label>
          <select
            value={memberId}
            onChange={(e) => setMemberId(e.target.value)}
            required
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          >
            <option value="">Select a member</option>
            {members.map((member) => (
              <option key={member.id} value={member.id}>
                {member.attributes.name}
              </option>
            ))}
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
          <select
            value={priority}
            onChange={(e) => setPriority(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          >
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
          <input
            type="text"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
          />
        </div>
      </div>
      <button
        type="submit"
        disabled={isSubmitting}
        className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
      >
        {isSubmitting ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );
}
Test the full stack task creation flow

The form is rendered and connected. Now you'll prove that the entire pipeline works: browser form to Next.js Client Component to Laravel API to SQLite database and back.

  • Make sure both dev servers are running. Your Laravel server should be on http://localhost:8000 and your Next.js server on http://localhost:3000.
  • Go to http://localhost:3000 in your browser.
  • Click the + New Task button to open the form.
  • Type Update project README in the Title field.
  • Select any team member from the Assign To dropdown.
  • Choose Low for Priority.
  • Type Add setup instructions and API examples in the Description field.
  • Click Create Task.

The page reloads and your new task appears in the Pending column with a gray "low" priority badge.

What just happened end-to-end?

  • Your Client Component called createTask() from api.ts, which sent a POST request to http://localhost:8000/api/tasks.
  • Laravel's TaskController@store validated the data and inserted a new row into your SQLite database.
  • The onSuccess callback triggered window.location.reload(), causing the Server Component in page.tsx to re-fetch all tasks from the API. The dashboard now includes the new task.

Task not appearing after submission?

  • If you see an alert saying "Failed to create task," open your browser's DevTools (F12) and check the Network tab. Look for a failed POST request to /api/tasks and read the response body for validation errors.
  • Make sure your Laravel dev server is still running. If it stopped, restart it with php artisan serve from inside your task-manager-api folder.
  • A CORS error in the console means the Laravel server is blocking cross-origin requests. Check that your Laravel CORS configuration allows requests from http://localhost:3000.

Help me debug why my task form submission isn't working.

Your full stack task manager is complete. You can display tasks from the API, create new ones through the form, and see them appear in real time. Next up, the Secret Mission adds filtering and search to make this dashboard truly production-ready.

Secret mission

Add Filtering and Search

Your task dashboard works, but real teams have dozens of tasks across different statuses. Can you add status filtering and a search bar so users can find exactly what they need?

Before you go

Clean Up Your Resources

This project runs entirely on your local machine with SQLite, so there are no ongoing cloud costs.

Decide whether to keep your resources as they are, pause them to come back later, or delete them entirely.

Resources you used:

  • Laravel 13 backend project (inside your task-manager folder).
  • Next.js 16 frontend project (inside your task-manager-frontend folder).
  • SQLite database file created by Laravel migrations.
  • PHP 8.3 installed via Chocolatey.
  • Composer 2.9 installed via Composer-Setup.exe.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Laravel and Next.js project folders stay on your Desktop.
  • PHP and Composer remain installed and available for future projects.
  • The SQLite database keeps your seeded demo data intact.

Pause - I'll come back to this later

Stop the running development servers to free up terminal sessions and ports. Your files and data stay safe on disk.

  • In each terminal window running a dev server, press Ctrl+C to stop the process.
  • Close the terminal windows.
  • When you come back, start the Laravel server with php artisan serve and the Next.js server with npm run dev.

Delete - I don't want to use this again

Remove all project files and optionally uninstall PHP and Composer. This frees up disk space and cleans your system.

  • Stop both dev servers by pressing Ctrl+C in each terminal.
  • Delete the Laravel backend folder from your Desktop.
  • Delete the Next.js frontend folder from your Desktop.
  • To uninstall PHP, run choco uninstall php -y in an elevated terminal.
  • To uninstall Composer, open Settings, select Apps, then Apps & features, find Composer, and click Uninstall.

Mission accomplished

That's a Wrap!

Nice work! You just built a complete full stack task management application from scratch.

You designed a backend REST API in Laravel 13, connected it to a Next.js 16 frontend, and shipped a working dashboard that fetches, displays, and creates tasks.

You've learned how to:

  • Design a scalable database schema with Eloquent relationships, migrations, and seeders in Laravel.
  • Build a JSON:API compliant REST API using Laravel 13's newest JsonApiResource feature.
  • Create a Next.js 16 dashboard using Server Components for data fetching and Client Components for interactive forms with TypeScript.
  • Add client-side filtering by status and a debounced search bar for a polished user experience.

Ready to quiz yourself?