Back to blog

Saturday, March 15, 2025

Building a Contact Management App with Laravel 12 and React

cover

Building a Contact Management App with Laravel 12 and React

This guide will walk you through creating a contact management application using Laravel 12 with React, Inertia.js, TypeScript, and Tailwind CSS. The app will allow users to create contact groups and add contacts to those groups.

Requirements

  1. Install PHP, Apache and MySQL Via XAMPP
  • First uninstall the existing one to make sure you have the latest PHP installed
  • Install a fresh XAMPP
  • Configure XAMPP so that Apache and MySQL start automatically on Windows start
  1. Install Composer

  2. Install the Laravel installer

  • First remove any existing installer if you have it
composer global remove laravel/installer
  • Add a fresh Laravel installer which supports interactivity
composer global require laravel/installer

Create a new Laravel app Using the installer

  • Use the Laravel installer
laravel new

Then it will start asking you questions, answer accordingly:

  • Select "Inertia with React" as the frontend stack
  • run the app
cd your-app-name
npm install && npm run build && npm run dev
  • In a new terminal tab:
php artisan serve

Project Requirements

  • Laravel 12 with Inertia.js and React starter kit (already installed)
  • User authentication with role management (user, admin)
  • Group management (with pop-up modal form)
  • Contact management (with dedicated page form)

Table of Contents

  1. Database Setup
  2. User Model Customization
  3. Creating the Group Feature
  4. Creating the Contact Feature
  5. Frontend Implementation with React & TypeScript
  6. Form Implementation
  7. UI Enhancements
  8. Testing
  9. Deployment

1. Database Setup

Configure Database Connection Using Mysql

Update your .env file with your database credentials:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=contacts_app
DB_USERNAME=root
DB_PASSWORD=

Configure Database Connection Using Postgress

You need to create a free account on render and also then also install postgress PgAdmin optionally Step 3: Enable PHP PostgreSQL Extensions Make sure these extensions are enabled in your php.ini file:

extension=php_pdo_pgsql.dll
extension=php_pgsql.dll

update ur .env

DB_CONNECTION=pgsql
DB_HOST=
DB_PORT=5432
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
DB_URL =

You may need to add SSL settings to your config/database.php file:

'pgsql' => [
    'driver' => 'pgsql',
    'url' => env('DATABASE_URL'),
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => 'utf8',
    'prefix' => '',
    'prefix_indexes' => true,
    'search_path' => 'public',
    'sslmode' => 'require',
]

Create Migrations

# Create migration for adding role and phone to users table
php artisan make:migration add_role_and_phone_to_users_table --table=users

# Create migration for groups
php artisan make:migration create_groups_table

# Create migration for contacts
php artisan make:migration create_contacts_table

Edit the migration files:

  1. database/migrations/xxxx_xx_xx_add_role_and_phone_to_users_table.php:
<?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::table('users', function (Blueprint $table) {
            $table->enum('role', ['user', 'admin'])->default('user');
            $table->string('phone')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['role', 'phone']);
        });
    }
};
  1. database/migrations/xxxx_xx_xx_create_groups_table.php:
<?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('groups', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('name');
            $table->string('slug')->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('groups');
    }
};
  1. database/migrations/xxxx_xx_xx_create_contacts_table.php:
<?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('contacts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('group_id')->constrained()->onDelete('cascade');
            $table->string('name');
            $table->string('phone');
            $table->string('email')->nullable();
            $table->timestamps();
        });
    }

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

Run the migrations:

php artisan migrate

2. User Model Customization

Update the User model to include the new fields:

php artisan make:enum UserRole

Edit app/Enums/UserRole.php:

<?php

namespace App\Enums;

enum UserRole: string
{
    case USER = 'user';
    case ADMIN = 'admin';
}

Update the User model (app/Models/User.php):

<?php

namespace App\Models;

use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
        'phone',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'role' => UserRole::class,
    ];

    /**
     * Get the groups for the user.
     */
    public function groups(): HasMany
    {
        return $this->hasMany(Group::class);
    }

    /**
     * Check if the user is an admin.
     */
    public function isAdmin(): bool
    {
        return $this->role === UserRole::ADMIN;
    }
}

3. Creating the Group Feature

Create the Model and Controller

# Create Group model with controller
php artisan make:model Group -c -r

Edit the Group model (app/Models/Group.php):

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;

class Group extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'user_id',
        'name',
        'slug',
    ];

    /**
     * Boot the model.
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($group) {
            $group->slug = Str::slug($group->name);
        });

        static::updating(function ($group) {
            $group->slug = Str::slug($group->name);
        });
    }

    /**
     * Get the user that owns the group.
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the contacts for the group.
     */
    public function contacts(): HasMany
    {
        return $this->hasMany(Contact::class);
    }
}

Edit the GroupController (app/Http/Controllers/GroupController.php):

<?php

namespace App\Http\Controllers;

use App\Models\Group;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;

class GroupController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index(): Response
    {
        $groups = Auth::user()->groups()->withCount('contacts')->get();

        return Inertia::render('Groups/Index', [
            'groups' => $groups,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
        ]);

        Auth::user()->groups()->create($validated);

        return redirect()->route('groups.index')->with('success', 'Group created successfully.');
    }

    /**
     * Display the specified resource.
     */
    public function show(Group $group): Response
    {
        $this->authorize('view', $group);

        $group->load('contacts');

        return Inertia::render('Groups/Show', [
            'group' => $group,
        ]);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Group $group): RedirectResponse
    {
        $this->authorize('update', $group);

        $validated = $request->validate([
            'name' => 'required|string|max:255',
        ]);

        $group->update($validated);

        return redirect()->route('groups.index')->with('success', 'Group updated successfully.');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Group $group): RedirectResponse
    {
        $this->authorize('delete', $group);

        $group->delete();

        return redirect()->route('groups.index')->with('success', 'Group deleted successfully.');
    }
}

Create a Policy for Group

php artisan make:policy GroupPolicy --model=Group

Edit app/Policies/GroupPolicy.php:

<?php

namespace App\Policies;

use App\Enums\UserRole;
use App\Models\Group;
use App\Models\User;

class GroupPolicy
{
    /**
     * Determine whether the user can view any models.
     */
    public function viewAny(User $user): bool
    {
        return true;
    }

    /**
     * Determine whether the user can view the model.
     */
    public function view(User $user, Group $group): bool
    {
        return $user->id === $group->user_id || $user->role === UserRole::ADMIN;
    }

    /**
     * Determine whether the user can create models.
     */
    public function create(User $user): bool
    {
        return true;
    }

    /**
     * Determine whether the user can update the model.
     */
    public function update(User $user, Group $group): bool
    {
        return $user->id === $group->user_id || $user->role === UserRole::ADMIN;
    }

    /**
     * Determine whether the user can delete the model.
     */
    public function delete(User $user, Group $group): bool
    {
        return $user->id === $group->user_id || $user->role === UserRole::ADMIN;
    }
}

Update Routes

Edit routes/web.php:

<?php

use App\Http\Controllers\ContactController;
use App\Http\Controllers\GroupController;
use App\Http\Controllers\ProfileController;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // Group Routes
    Route::resource('groups', GroupController::class);

    // Contact Routes
    Route::resource('groups.contacts', ContactController::class)->shallow();
});

require __DIR__.'/auth.php';

4. Creating the Contact Feature

Create the Model and Controller

# Create Contact model with controller
php artisan make:model Contact -c -r

Edit the Contact model (app/Models/Contact.php):

<?php

namespace App\Models;

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

class Contact extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'group_id',
        'name',
        'phone',
        'email',
    ];

    /**
     * Get the group that owns the contact.
     */
    public function group(): BelongsTo
    {
        return $this->belongsTo(Group::class);
    }
}

Edit the ContactController (app/Http/Controllers/ContactController.php):

<?php

namespace App\Http\Controllers;

use App\Models\Contact;
use App\Models\Group;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class ContactController extends Controller
{
    /**
     * Show the form for creating a new resource.
     */
    public function create(Group $group): Response
    {
        $this->authorize('view', $group);

        return Inertia::render('Contacts/Create', [
            'group' => $group,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request, Group $group): RedirectResponse
    {
        $this->authorize('view', $group);

        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'phone' => 'required|string|max:20',
            'email' => 'nullable|email|max:255',
        ]);

        $group->contacts()->create($validated);

        return redirect()->route('groups.show', $group)
            ->with('success', 'Contact created successfully.');
    }

    /**
     * Display the specified resource.
     */
    public function show(Contact $contact): Response
    {
        $this->authorize('view', $contact);

        $contact->load('group');

        return Inertia::render('Contacts/Show', [
            'contact' => $contact,
        ]);
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(Contact $contact): Response
    {
        $this->authorize('update', $contact);

        $contact->load('group');

        return Inertia::render('Contacts/Edit', [
            'contact' => $contact,
        ]);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Contact $contact): RedirectResponse
    {
        $this->authorize('update', $contact);

        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'phone' => 'required|string|max:20',
            'email' => 'nullable|email|max:255',
        ]);

        $contact->update($validated);

        return redirect()->route('groups.show', $contact->group)
            ->with('success', 'Contact updated successfully.');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Contact $contact): RedirectResponse
    {
        $this->authorize('delete', $contact);

        $group = $contact->group;
        $contact->delete();

        return redirect()->route('groups.show', $group)
            ->with('success', 'Contact deleted successfully.');
    }
}

Create a Policy for Contact

php artisan make:policy ContactPolicy --model=Contact

Edit app/Policies/ContactPolicy.php:

<?php

namespace App\Policies;

use App\Enums\UserRole;
use App\Models\Contact;
use App\Models\User;

class ContactPolicy
{
    /**
     * Determine whether the user can view the model.
     */
    public function view(User $user, Contact $contact): bool
    {
        return $user->id === $contact->group->user_id || $user->role === UserRole::ADMIN;
    }

    /**
     * Determine whether the user can update the model.
     */
    public function update(User $user, Contact $contact): bool
    {
        return $user->id === $contact->group->user_id || $user->role === UserRole::ADMIN;
    }

    /**
     * Determine whether the user can delete the model.
     */
    public function delete(User $user, Contact $contact): bool
    {
        return $user->id === $contact->group->user_id || $user->role === UserRole::ADMIN;
    }
}

5. Frontend Implementation with React & TypeScript

Create TypeScript interfaces for your data models. Create a file resources/js/types/index.ts:

export interface User {
  id: number;
  name: string;
  email: string;
  phone?: string;
  role: "user" | "admin";
  email_verified_at?: string;
}

export interface Group {
  id: number;
  user_id: number;
  name: string;
  slug: string;
  contacts_count?: number;
  contacts?: Contact[];
  created_at: string;
  updated_at: string;
}

export interface Contact {
  id: number;
  group_id: number;
  name: string;
  phone: string;
  email?: string;
  created_at: string;
  updated_at: string;
  group?: Group;
}

export interface PageProps {
  auth: {
    user: User;
  };
  flash: {
    success?: string;
    error?: string;
  };
}

6. Form Implementation

Create a Group Modal Component

Create a file resources/js/Components/GroupModal.tsx:

import { Fragment, useEffect } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { useForm } from "@inertiajs/react";
import InputError from "@/Components/InputError";
import InputLabel from "@/Components/InputLabel";
import TextInput from "@/Components/TextInput";
import PrimaryButton from "@/Components/PrimaryButton";
import SecondaryButton from "@/Components/SecondaryButton";
import { Group } from "@/types";

interface GroupModalProps {
  show: boolean;
  onClose: () => void;
  group?: Group | null;
}

export default function GroupModal({
  show,
  onClose,
  group = null,
}: GroupModalProps) {
  const { data, setData, post, put, processing, errors, reset } = useForm({
    name: "",
  });

  useEffect(() => {
    if (show) {
      setData("name", group ? group.name : "");
    } else {
      reset();
    }
  }, [show, group]);

  const submit = (e: React.FormEvent) => {
    e.preventDefault();

    if (group) {
      put(route("groups.update", group.id), {
        onSuccess: () => onClose(),
      });
    } else {
      post(route("groups.store"), {
        onSuccess: () => onClose(),
      });
    }
  };

  return (
    <Transition show={show} as={Fragment}>
      <Dialog as="div" className="relative z-10" onClose={onClose}>
        <Transition.Child
          as={Fragment}
          enter="ease-out duration-300"
          enterFrom="opacity-0"
          enterTo="opacity-100"
          leave="ease-in duration-200"
          leaveFrom="opacity-100"
          leaveTo="opacity-0"
        >
          <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
        </Transition.Child>

        <div className="fixed inset-0 z-10 overflow-y-auto">
          <div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
            <Transition.Child
              as={Fragment}
              enter="ease-out duration-300"
              enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
              enterTo="opacity-100 translate-y-0 sm:scale-100"
              leave="ease-in duration-200"
              leaveFrom="opacity-100 translate-y-0 sm:scale-100"
              leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            >
              <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
                <div>
                  <Dialog.Title
                    as="h3"
                    className="text-lg font-medium leading-6 text-gray-900"
                  >
                    {group ? "Edit Group" : "Create New Group"}
                  </Dialog.Title>
                  <div className="mt-2">
                    <form onSubmit={submit} className="space-y-6">
                      <div>
                        <InputLabel htmlFor="name" value="Name" />
                        <TextInput
                          id="name"
                          className="mt-1 block w-full"
                          value={data.name}
                          onChange={(e) => setData("name", e.target.value)}
                          required
                          autoFocus
                        />
                        <InputError message={errors.name} className="mt-2" />
                      </div>

                      <div className="mt-6 flex justify-end space-x-2">
                        <SecondaryButton onClick={onClose}>
                          Cancel
                        </SecondaryButton>
                        <PrimaryButton type="submit" disabled={processing}>
                          {group ? "Update" : "Create"}
                        </PrimaryButton>
                      </div>
                    </form>
                  </div>
                </div>
              </Dialog.Panel>
            </Transition.Child>
          </div>
        </div>
      </Dialog>
    </Transition>
  );
}

Create Groups Index Page

Create a file resources/js/Pages/Groups/Index.tsx:

import { useState } from "react";
import { Head, Link } from "@inertiajs/react";
import { PageProps, Group } from "@/types";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import GroupModal from "@/Components/GroupModal";
import PrimaryButton from "@/Components/PrimaryButton";
import DangerButton from "@/Components/DangerButton";
import Modal from "@/Components/Modal";

interface Props extends PageProps {
  groups: Group[];
}

export default function Index({ auth, groups, flash }: Props) {
  const [showCreateModal, setShowCreateModal] = useState(false);
  const [editingGroup, setEditingGroup] = useState<Group | null>(null);
  const [groupToDelete, setGroupToDelete] = useState<Group | null>(null);

  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 leading-tight">
          Groups
        </h2>
      }
    >
      <Head title="Groups" />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          {flash.success && (
            <div className="mb-4 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
              {flash.success}
            </div>
          )}

          <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <div className="p-6 text-gray-900">
              <div className="flex justify-between items-center mb-6">
                <h3 className="text-lg font-medium">Your Contact Groups</h3>
                <PrimaryButton onClick={() => setShowCreateModal(true)}>
                  Create New Group
                </PrimaryButton>
              </div>

              {groups.length === 0 ? (
                <p className="text-gray-500">
                  You don't have any contact groups yet. Create one to get
                  started!
                </p>
              ) : (
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
                  {groups.map((group) => (
                    <div
                      key={group.id}
                      className="border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
                    >
                      <div className="flex justify-between items-start">
                        <Link href={route("groups.show", group.id)}>
                          <h4 className="text-lg font-semibold text-blue-600 hover:text-blue-800">
                            {group.name}
                          </h4>
                        </Link>
                        <div className="flex space-x-2">
                          <button
                            onClick={() => setEditingGroup(group)}
                            className="text-gray-500 hover:text-gray-700"
                          >
                            <svg
                              xmlns="http://www.w3.org/2000/svg"
                              className="h-5 w-5"
                              viewBox="0 0 20 20"
                              fill="currentColor"
                            >
                              <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
                            </svg>
                          </button>
                          <button
                            onClick={() => setGroupToDelete(group)}
                            className="text-red-500 hover:text-red-700"
                          >
                            <svg
                              xmlns="http://www.w3.org/2000/svg"
                              className="h-5 w-5"
                              viewBox="0 0 20 20"
                              fill="currentColor"
                            >
                              <path
                                fillRule="evenodd"
                                d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
                                clipRule="evenodd"
                              />
                            </svg>
                          </button>
                        </div>
                      </div>
                      <p className="text-gray-600 mt-2">
                        {group.contacts_count || 0} contacts
                      </p>
                    </div>
                  ))}
                </div>
              )}
            </div>
          </div>
        </div>
      </div>

      <GroupModal
        show={showCreateModal}
        onClose={() => setShowCreateModal(false)}
      />

      <GroupModal
        show={!!editingGroup}
        onClose={() => setEditingGroup(null)}
        group={editingGroup}
      />

      <Modal show={!!groupToDelete} onClose={() => setGroupToDelete(null)}>
        <div className="p-6">
          <h2 className="text-lg font-medium text-gray-900">
            Are you sure you want to delete this group?
          </h2>
          <p className="mt-1 text-sm text-gray-600">
            This will also delete all contacts in this group. This action cannot
            be undone.
          </p>
          <div className="mt-6 flex justify-end space-x-2">
            <PrimaryButton onClick={() => setGroupToDelete(null)}>
              Cancel
            </PrimaryButton>
            <DangerButton
              onClick={() => {
                if (groupToDelete) {
                  window.location.href = route(
                    "groups.destroy",
                    groupToDelete.id
                  );
                }
                setGroupToDelete(null);
              }}
            >
              Delete
            </DangerButton>
          </div>
        </div>
      </Modal>
    </AuthenticatedLayout>
  );
}

Create Group Show Page

Create a file resources/js/Pages/Groups/Show.tsx:

import { Head, Link, router } from "@inertiajs/react";
import { PageProps, Group, Contact } from "@/types";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import PrimaryButton from "@/Components/PrimaryButton";

interface Props extends PageProps {
  group: Group & { contacts: Contact[] };
}

export default function Show({ auth, group, flash }: Props) {
  const deleteContact = (contactId: number) => {
    if (confirm("Are you sure you want to delete this contact?")) {
      router.delete(route("contacts.destroy", contactId));
    }
  };

  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 leading-tight">
          {group.name}
        </h2>
      }
    >
      <Head title={group.name} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          {flash.success && (
            <div className="mb-4 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
              {flash.success}
            </div>
          )}

          <div className="mb-4 flex justify-between items-center">
            <Link
              href={route("groups.index")}
              className="text-blue-600 hover:text-blue-800 flex items-center"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                className="h-5 w-5 mr-1"
                viewBox="0 0 20 20"
                fill="currentColor"
              >
                <path
                  fillRule="evenodd"
                  d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
                  clipRule="evenodd"
                />
              </svg>
              Back to Groups
            </Link>
            <Link href={route("groups.contacts.create", group.id)}>
              <PrimaryButton>Add New Contact</PrimaryButton>
            </Link>
          </div>

          <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <div className="p-6 text-gray-900">
              <h3 className="text-lg font-medium mb-4">
                Contacts in {group.name}
              </h3>

              {group.contacts.length === 0 ? (
                <p className="text-gray-500">
                  No contacts in this group yet. Add one to get started!
                </p>
              ) : (
                <div className="overflow-x-auto">
                  <table className="min-w-full divide-y divide-gray-200">
                    <thead className="bg-gray-50">
                      <tr>
                        <th
                          scope="col"
                          className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
                        >
                          Name
                        </th>
                        <th
                          scope="col"
                          className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
                        >
                          Phone
                        </th>
                        <th
                          scope="col"
                          className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
                        >
                          Email
                        </th>
                        <th
                          scope="col"
                          className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
                        >
                          Actions
                        </th>
                      </tr>
                    </thead>
                    <tbody className="bg-white divide-y divide-gray-200">
                      {group.contacts.map((contact) => (
                        <tr key={contact.id}>
                          <td className="px-6 py-4 whitespace-nowrap">
                            <div className="text-sm font-medium text-gray-900">
                              {contact.name}
                            </div>
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap">
                            <div className="text-sm text-gray-500">
                              {contact.phone}
                            </div>
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap">
                            <div className="text-sm text-gray-500">
                              {contact.email}
                            </div>
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                            <div className="flex justify-end space-x-2">
                              <Link
                                href={route("contacts.edit", contact.id)}
                                className="text-indigo-600 hover:text-indigo-900"
                              >
                                Edit
                              </Link>
                              <button
                                onClick={() => deleteContact(contact.id)}
                                className="text-red-600 hover:text-red-900"
                              >
                                Delete
                              </button>
                            </div>
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </AuthenticatedLayout>
  );
}