Build a Full-Stack Person Directory

Build a React and Django directory with CRUD APIs and image uploads.

Introduction

30 Second Summary

A people directory is easy to outgrow when portraits live apart from personal details. Updating one record can turn into a hunt across folders.

In this project, you will build a local Person Directory with React backed by Django REST Framework. It will persist each record through a REST API that accepts portrait uploads.

What You'll Build

After you submit a portrait from the polished React page, the new person appears immediately in a responsive card grid.

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

  • A responsive person directory where each card shows a portrait beside the person's details.
  • A multipart upload flow that saves each selected portrait with its matching person record.
  • Record management controls that let you correct a profile. You can also replace its portrait. Delete removes the record without a manual page refresh.
  • Secret Mission: Add an instant image preview with a client-side image type guard.

Are there any prerequisites?

You'll need basic familiarity with Python or JavaScript. Step 1 checks your local Python, Node.js, and Visual Studio Code installations before either app is created.

Before We Start

This first checkpoint defines who your local Person Directory serves. It also captures why a portrait makes each record recognizable.

Set Up the Two Applications

Your Person Directory needs a Django backend to store data. The React frontend needs its own JavaScript toolchain.

A Python virtual environment keeps this project's packages isolated. This step gives both applications a working local starting point in Visual Studio Code.

In this step, get ready to:
  • Confirm the required Python and Node.js runtime versions.
  • Install the pinned backend packages in an isolated environment.
  • Scaffold the Django backend and Vite frontend.
Confirm the tool versions

Django 6.1 requires Python 3.12+. Vite requires Node.js 20.19+ or 22.12+.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.
  • Click View in the top menu.
  • Click Terminal.
  • Select the Launch Profile dropdown in the terminal panel.
  • Choose Command Prompt.
  • Check both runtime versions by running these commands:
python --version
node --version

What do these commands check?

  • The first command prints the Python version available through python.
  • The second command prints the Node.js version available through node.
  • Both checks confirm that the installed runtimes can support the pinned project tools.

✔️ I see Python 3.12 or higher

Python meets Django's minimum requirement. Keep this Command Prompt terminal open.

ⓧ I see an older Python version

The installed Python version cannot run Django 6.1. Upgrade Python before creating the virtual environment.

  • Open the official Python Releases for Windows page.
  • Download a Windows installer for Python 3.12+.
  • Run the downloaded installer.
  • Allow the installer to make Python available from Command Prompt.
  • Restart Visual Studio Code after the installation finishes.
  • Run the Python version check again in a new Command Prompt terminal.

ⓧ Python is not found

Command Prompt cannot find a Python installation. Install a compatible version before continuing.

  • Open the official Python Releases for Windows page.
  • Download a Windows installer for Python 3.12+.
  • Run the downloaded installer.
  • Allow the installer to make Python available from Command Prompt.
  • Restart Visual Studio Code after the installation finishes.
  • Run the Python version check again in a new Command Prompt terminal.

✔️ I see Node.js 20.19+ or 22.12+

Node.js meets Vite's runtime requirement. You can use it to scaffold the frontend.

ⓧ I see an older Node.js version

The installed Node.js version cannot run the pinned Vite release. Upgrade Node.js before scaffolding the frontend.

  • Open the official Node.js download page.
  • Download a Windows installer that satisfies 20.19+ or 22.12+.
  • Run the downloaded installer.
  • Restart Visual Studio Code after the installation finishes.
  • Run the Node.js version check again in a new Command Prompt terminal.

ⓧ Node.js is not found

Command Prompt cannot find a Node.js installation. Install a compatible version before continuing.

  • Open the official Node.js download page.
  • Download a Windows installer that satisfies 20.19+ or 22.12+.
  • Run the downloaded installer.
  • Restart Visual Studio Code after the installation finishes.
  • Run the Node.js version check again in a new Command Prompt terminal.

Both runtime checks are now green. Your machine can run the backend and frontend toolchains.

  • Create the person-directory workspace on your Desktop by running these commands:
cd /d "%USERPROFILE%\Desktop"
mkdir person-directory
cd person-directory

What do these commands create?

  • The first command moves Command Prompt to your Desktop.
  • The second command creates the person-directory folder.
  • The final command moves the terminal into the new folder.
  • Click File in the Visual Studio Code menu.
  • Click Open Folder.
  • Select the person-directory folder on your Desktop.
  • Confirm the folder choice in the Windows dialog.
  • Click View after the workspace opens.
  • Click Terminal.
  • Choose Command Prompt from the terminal profile dropdown.
  • Create the virtual environment and activate it by running:
python -m venv .venv
.venv\Scripts\activate.bat

Why use a virtual environment?

The virtual environment stores this project's Python packages inside .venv. Other Python projects keep their own package versions.

The environment name now appears at the beginning of your terminal prompt. That prompt confirms future Python installs stay inside this workspace.

Virtual environment not active?

  • Confirm the terminal profile says Command Prompt.
  • Confirm the terminal path ends with person-directory.
  • Run the activation command again after the .venv folder appears in the Explorer sidebar.
  • Help me activate the .venv environment in Windows Command Prompt.
Install Python packages and scaffold Django

The requirements.txt file pins every backend package to the project version. Reinstalling from this file produces the same backend toolchain.

  • Click the New File control in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Fill the new file with the pinned backend packages by pasting this code:
Django==6.1
djangorestframework==3.18.0
Pillow==12.3.0

What does this file install?

  • Django 6.1 supplies the backend framework.
  • Django REST framework 3.18.0 supplies the API tools.
  • Pillow 12.3.0 lets Django validate uploaded images.
  • Save requirements.txt.
  • Install the pinned backend packages by running this command:
python -m pip install -r requirements.txt

What does this command do?

The active environment's Python reads requirements.txt. It installs the three exact package versions into .venv.

Package installation failed?

  • Confirm the terminal prompt shows the active .venv environment.
  • Confirm requirements.txt sits directly inside person-directory.
  • Check that your internet connection can reach the Python package registry.
  • Help me diagnose my pinned Python package installation.

Before you check the installation, what Django version do you expect the environment to report?

  • Confirm the installed Django version by running:
python -m django --version

What should you see?

The terminal prints 6.1. This confirms that Command Prompt is using the pinned Django installation from your virtual environment.

  • Create the backend project and its first app by running these commands:
django-admin startproject config backend
cd backend
python manage.py startapp people

What does this scaffold create?

  • The first command creates the Django project beneath backend.
  • The second command moves the terminal into backend.
  • The final command creates the people app beside manage.py.
  • Expand backend in the Explorer sidebar.
  • Confirm manage.py appears beneath backend.
  • Confirm the config folder contains settings.py.
  • Confirm the people folder contains models.py.

Backend folders missing?

  • Confirm the terminal was inside person-directory before the scaffold command started.
  • Confirm the Django version check printed 6.1.
  • Refresh the Explorer sidebar if the generated files do not appear immediately.
  • Help me find my Django scaffold files.

✔️ My backend scaffold is ready

Your pinned requirements file is saved. The config project and people app are ready for the API work.

ⓧ I'd like to double check the backend

Compare your requirements.txt with this complete reference:

Django==6.1
djangorestframework==3.18.0
Pillow==12.3.0
  • Confirm backend/config/settings.py remains the Django 6.1 scaffold settings file.
  • Confirm backend/config/urls.py remains the Django 6.1 scaffold URL configuration.
  • Confirm backend/people/models.py remains the generated model scaffold.
Scaffold and run the frontend

The frontend scaffold provides a working React page before you add directory features. Exact package pins keep the generated application compatible with Vite 8.2.2.

  • Approve the scaffold package download if npm requests permission.
  • Return to the workspace folder and create the React frontend by running these commands:
cd ..
npm create vite@9.2.0 frontend -- --template react

What does this scaffold create?

The first command returns the terminal to person-directory. The second command creates a React starter application beneath frontend.

The generated project includes index.html plus the starter modules beneath src.

Frontend scaffold failed?

  • Confirm the Node.js check meets Vite's runtime requirement.
  • Confirm the terminal path is person-directory.
  • Check your internet connection if npm cannot download the scaffold package.
  • Help me diagnose the Vite scaffold command.
  • Expand frontend in the Explorer sidebar.
  • Confirm index.html appears inside frontend.
  • Confirm App.jsx appears inside frontend/src.
  • Move the terminal into the frontend folder by running:
cd frontend

What does this command do?

The terminal moves into frontend. Package installation and Vite commands now target the React application.

  • Click frontend/package.json in the Explorer sidebar.
  • Select all existing content in package.json.
  • Replace the selected content with the pinned package configuration below:
{
  "name": "person-directory-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "1.20.0",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}

What does this configuration pin?

  • The scripts provide commands for development, production builds, and local build previews.
  • React 19.2.8 supplies the interface runtime.
  • Axios 1.20.0 supplies the HTTP client used later in the directory.
  • Vite 8.2.2 supplies the development server and build process.
  • Save frontend/package.json.
  • Install the pinned frontend dependencies by running:
npm install

What does this command install?

npm reads the exact versions from package.json. It stores the installed packages beneath frontend/node_modules.

Frontend dependencies not installed?

  • Confirm the terminal path ends with person-directory\frontend.
  • Confirm frontend/package.json contains valid JSON with matching braces.
  • Check your internet connection if npm cannot download a package.
  • Help me diagnose npm install in the frontend folder.

✔️ My frontend dependencies are ready

The frontend now has its pinned dependencies. Double check that frontend/package.json is saved.

ⓧ I'd like to double check the full code

Compare your complete frontend/package.json with this reference:

{
  "name": "person-directory-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "1.20.0",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.2.2"
  }
}
  • Confirm frontend/index.html contains the generated root element and /src/main.jsx module entry.
  • Confirm frontend/src/App.jsx remains the Vite React starter component.
  • Confirm frontend/src/index.css remains the Vite React starter stylesheet.
  • Confirm frontend/src/main.jsx remains the Vite React entry module.

Before you start both applications, which terminal do you expect to print the Vite URL?

  • Start the frontend development server in the current terminal by running:
npm run dev

What should you see?

Vite prints its local development URL in the terminal. Keep this terminal running while you check the starter page.

  • Ctrl-click the local URL printed by Vite.
  • Confirm the Vite React starter page opens in your browser.
  • Click the plus control in the Visual Studio Code terminal panel.
  • Choose Command Prompt for the new terminal.
  • Activate the virtual environment and start Django by running these commands:
.venv\Scripts\activate.bat
python -m django --version
cd backend
python manage.py runserver

What should the backend terminal show?

The terminal first prints 6.1. Django then starts its development server at http://127.0.0.1:8000/.

Keep this terminal running beside the Vite terminal. Each application now has its own local server.

  • Ctrl-click http://127.0.0.1:8000/ in the Django terminal.
  • Confirm the Django starter page opens in your browser.

That is the setup complete. Your Django backend and React frontend now run from the same workspace.

One of the starter pages not loading?

  • Confirm the Vite terminal remains open with its development process running.
  • Confirm the Django terminal remains open with its development process running.
  • Check that the Django terminal path ends with person-directory\backend.
  • Help me diagnose my two local development servers.

Your isolated toolchain is ready. Next, you'll turn the Django scaffold into a persistent Person API.

Build the Person API

Your Django backend now runs beside your React starter app. The frontend still has no persistent people to display.

In this step, you'll build a REST API with Django REST Framework. Each person will persist in SQLite with an uploaded portrait.

In this step, get ready to:
  • Define the person data structure with local image storage.
  • Expose collection routes plus detail routes through a serializer and viewset.
  • Create the database migration before uploading your first person.
Configure Django for person data

A model defines which values each person can store. Django also needs the app registration plus media settings before it can manage these records.

  • In the Explorer sidebar in Visual Studio Code, open backend/config/settings.py.
  • Select everything from the first line through the closing bracket of INSTALLED_APPS.
  • Replace the selected section by pasting this code:
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = "django-insecure-local-development-only"
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "people",
]

What does this configuration do?

  • The local development settings let Django respond through the two host names used in this project.
  • The rest_framework entry activates Django REST Framework.
  • The people entry activates your app so Django can discover its model.
  • Select the existing section from MIDDLEWARE through ROOT_URLCONF.
  • Replace the selected section by pasting this code:
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "config.urls"

What does this section control?

The middleware list keeps Django's request processing layers in place. ROOT_URLCONF points Django to the file that will expose your API routes.

  • Save backend/config/settings.py.
  • Switch back to the activated backend Command Prompt terminal from earlier.
  • Start the Django server by running this command:
python manage.py runserver

What does this command check?

Django loads the settings file before starting its development server. A successful startup confirms that the first configuration sections have valid Python syntax.

You should see Django start at http://127.0.0.1:8000/. Keep this server running so it can reload after each saved backend change.

Does the server stop with a settings error?

  • Check that the INSTALLED_APPS list has one closing bracket.
  • Confirm every app name remains inside matching quotation marks.
  • Ask for help with the exact terminal output: Help me debug my Django settings error.
  • Return to backend/config/settings.py in the editor.
  • Select the existing section from TEMPLATES through WSGI_APPLICATION.
  • Replace the selected section by pasting this code:
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "config.wsgi.application"

What does this section preserve?

The template settings support Django's built-in pages. The WSGI setting identifies the application entry point used by the local server.

  • Save backend/config/settings.py.
  • Return to the backend terminal.

You should see the development server reload without a settings syntax error.

Does the template section fail to load?

  • Check that each opening brace in TEMPLATES has a matching closing brace.
  • Confirm context_processors remains nested inside OPTIONS.
  • Ask for help with the nesting: Help me fix the TEMPLATES structure.
  • Select the existing section from DATABASES through the end of backend/config/settings.py.
  • Replace the selected section by pasting this code:
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

AUTH_PASSWORD_VALIDATORS = []

LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

How will data and images be stored?

  • The database configuration keeps Person records in backend/db.sqlite3.
  • MEDIA_ROOT places uploaded files inside backend/media.
  • MEDIA_URL gives those files the local URL prefix used by the API response.
  • Save backend/config/settings.py.
  • Return to the backend terminal.

You should see another clean reload. Django now knows where to persist database rows plus uploaded media.

Seeing a database or media setting error?

  • Confirm both paths use BASE_DIR exactly as shown.
  • Check that MEDIA_URL ends with a forward slash.
  • Ask for help with the saved configuration: Help me check my database and media settings.

The Person model now needs four fields. Its ImageField uses Pillow to validate uploaded image content.

  • Open backend/people/models.py from the Explorer sidebar.
  • Replace the entire file by pasting this code:
from django.db import models


class Person(models.Model):
    name = models.CharField(max_length=100)
    age = models.IntegerField()
    place = models.CharField(max_length=100)
    image = models.ImageField(upload_to="people/")

    def __str__(self):
        return self.name

What does this model define?

  • The name field stores up to 100 characters.
  • The age field stores a whole number.
  • The place field stores up to 100 characters.
  • The image field sends uploads into the people/ subdirectory beneath MEDIA_ROOT.
  • Save backend/people/models.py.
  • Return to the backend terminal.

The server reloads with the new model definition. A migration warning is expected until you create the database table in the final substep.

Does the model trigger a syntax error?

  • Check that every field is indented inside the Person class.
  • Confirm return self.name is indented inside __str__().
  • Ask for help with the model structure: Help me debug my Person model.
Expose the CRUD routes

A serializer converts each Person between model data and API data. A viewset applies standard create, read, update, plus delete behavior to that representation.

  • In the backend/people folder in the Explorer sidebar, create a file named serializers.py.
  • Add the Person serializer by pasting this code:
from rest_framework import serializers

from .models import Person


class PersonSerializer(serializers.ModelSerializer):
    image = serializers.ImageField(use_url=True)

    class Meta:
        model = Person
        fields = ["id", "name", "age", "place", "image"]

What does the serializer expose?

  • PersonSerializer maps the Person model to an API representation.
  • use_url=True returns an image URL in each response.
  • fields limits every response to the five named values.
  • Save backend/people/serializers.py.
  • Return to the backend terminal.

You should see the server reload after the new file is saved.

Is the serializer file showing an error?

  • Confirm serializers.py is inside backend/people.
  • Check that Meta is indented inside PersonSerializer.
  • Ask for help with the serializer: Help me debug PersonSerializer.
  • Open backend/people/views.py from the Explorer sidebar.
  • Replace the entire file by pasting this code:
from rest_framework import viewsets

from .models import Person
from .serializers import PersonSerializer


class PersonViewSet(viewsets.ModelViewSet):
    queryset = Person.objects.all()
    serializer_class = PersonSerializer

What does the viewset provide?

  • Person.objects.all() supplies the full Person collection.
  • serializer_class tells the viewset how to validate requests.
  • ModelViewSet supplies the standard CRUD actions used by the router.
  • Save backend/people/views.py.
  • Return to the backend terminal.

You should see another server reload. The viewset is ready for a URL registration.

Does the viewset fail to import?

  • Confirm the serializer file is named serializers.py.
  • Check that the imports begin with a single dot for the local files.
  • Ask for help with the imports: Help me fix my PersonViewSet imports.
  • In the backend/people folder in the Explorer sidebar, create a file named urls.py.
  • Register the Person viewset by pasting this code:
from rest_framework.routers import DefaultRouter

from .views import PersonViewSet

router = DefaultRouter()
router.register("persons", PersonViewSet)

urlpatterns = router.urls

What does the router generate?

  • The persons prefix creates the collection route at /api/persons/ after the project URL prefix is added.
  • The same registration creates detail routes at /api/persons/{id}/.
  • urlpatterns exposes every generated route to Django.
  • Save backend/people/urls.py.
  • Return to the backend terminal.

You should see the server reload after the router file is created.

Does the router registration fail?

  • Check that urls.py is inside backend/people.
  • Confirm the router prefix has no trailing slash.
  • Ask for help with the registration: Help me debug my DefaultRouter setup.
  • Open backend/config/urls.py from the Explorer sidebar.
  • Replace the entire file by pasting this code:
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("people.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

How does the project expose the API?

  • The api/ path includes every route from people.urls.
  • static() serves uploaded portraits from MEDIA_ROOT during local development.
  • The media helper is intended for debug-mode development on your machine.
  • Save backend/config/urls.py.
  • Return to the backend terminal.

You should see Django reload with the API route connected. The database table is the final missing piece.

Does Django report a URL configuration error?

  • Confirm include is imported from django.urls.
  • Check that people.urls matches the file inside backend/people.
  • Ask for help with the route wiring: Help me debug my Django URL configuration.

Why a viewset and router?

A separate view for every CRUD action would repeat request handling plus URL wiring. ModelViewSet groups those actions around one resource.

DefaultRouter converts that resource controller into collection routes plus detail routes. This keeps the API structure consistent as the project grows.

  • Compare your authored backend files with the complete references in the second tab.

✔️ Awesome, I've got everything!

Great. Double check that every backend file is saved before you create the migration.

ⓧ I'd like to double check the full code

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = "django-insecure-local-development-only"
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "people",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "config.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "config.wsgi.application"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

AUTH_PASSWORD_VALIDATORS = []

LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

Settings File Check

This reference contains the complete Django configuration required by the Person API.

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("people.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Project URL File Check

This reference connects the API prefix plus local media serving.

from django.db import models


class Person(models.Model):
    name = models.CharField(max_length=100)
    age = models.IntegerField()
    place = models.CharField(max_length=100)
    image = models.ImageField(upload_to="people/")

    def __str__(self):
        return self.name

Model File Check

This reference contains the complete Person model used by the migration.

from rest_framework import serializers

from .models import Person


class PersonSerializer(serializers.ModelSerializer):
    image = serializers.ImageField(use_url=True)

    class Meta:
        model = Person
        fields = ["id", "name", "age", "place", "image"]

Serializer File Check

This reference contains the complete API representation for a Person.

from rest_framework import viewsets

from .models import Person
from .serializers import PersonSerializer


class PersonViewSet(viewsets.ModelViewSet):
    queryset = Person.objects.all()
    serializer_class = PersonSerializer

View File Check

This reference contains the complete Person viewset.

from rest_framework.routers import DefaultRouter

from .views import PersonViewSet

router = DefaultRouter()
router.register("persons", PersonViewSet)

urlpatterns = router.urls

App URL File Check

This reference contains the complete router registration for the Person resource.

Migrate and test the API

The model describes the Person table in Python. A migration turns that definition into a real table inside backend/db.sqlite3.

  • Stop the running Django server by pressing Ctrl+C in the backend terminal.
  • Create the Person migration plus apply every pending migration by running these commands:
python manage.py makemigrations people
python manage.py migrate

What do these migration commands do?

  • The first command creates backend/people/migrations/0001_initial.py from the Person model.
  • The second command creates the required tables inside backend/db.sqlite3.

You should see Django create the initial people migration. You should also see the pending migrations complete successfully.

Did the migration fail?

  • Confirm the virtual environment is still activated in the backend terminal.
  • Check that people remains listed in INSTALLED_APPS.
  • Ask for help with the migration output: Help me fix my Person migration.

Before you test the endpoint, what fields do you expect the browsable API to offer for a new Person?

  • Restart the Django development server by running this command:
python manage.py runserver

What does the server expose now?

Django now loads the migrated Person table plus the router-backed API. Local media serving also makes uploaded portraits available while debug mode is enabled.

  • Enter http://127.0.0.1:8000/api/persons/ in your browser address bar.
  • Enter Ada Lovelace in the Name field.
  • Enter 36 in the Age field.
  • Enter London in the Place field.
  • Use the Image field to select a non-sensitive image from your computer.
  • Submit the record with the POST button.

You should see a returned record containing id, name, age, place, plus an image URL.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Expand backend/media/people/ to confirm the uploaded image file is stored there.

That is the backend foundation complete. Your API now persists Person records plus their portraits.

Does the upload return an error?

  • Confirm every form field has a value before submitting the record.
  • Choose an actual image file for the Image field.
  • Check that both migration commands completed before the server was restarted.
  • Ask for help with the response: Help me debug my browsable API upload.

Your Person API is ready for a frontend. Next up, you'll replace the React starter page with a directory that loads these records as portrait cards.

Display API Data in React

Your Django REST Framework API now stores people with their portraits. Those records are still visible only through the browsable API.

This step turns that backend data into a visible React directory. A Vite development proxy carries relative API requests from Axios to Django.

In this step, get ready to:
  • Proxy frontend API requests to the Django server.
  • Load stored people into React state with Axios.
  • Display loading states and person cards in a responsive directory.
Connect Vite to Django

The frontend runs on a different local address from Django. The development proxy forwards every request beginning with /api to the backend.

Why use a development proxy?

The proxy lets Axios use relative paths such as /api/persons/. This keeps the local connection inside Vite without adding another backend package.

  • Select frontend/vite.config.js from the file list on the left side of Visual Studio Code.
  • Replace the file contents with this proxy configuration:
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:8000",
    },
  },
})

What does this configuration do?

  • The plugins setting keeps React support active inside Vite.
  • The server.proxy setting matches requests that begin with /api.
  • The proxy sends matching requests to the Django server at http://127.0.0.1:8000.
  • Save frontend/vite.config.js.
  • Return to the frontend terminal from the setup step.
  • Start the Vite development server by running:
npm run dev

What does this command do?

The dev script starts Vite with the proxy configuration you just saved. The terminal prints the local development URL for your React page.

  • Record the development URL shown in the frontend terminal: your displayed Vite URL.

Before you test the proxy, do you expect the Vite address to return the same records as Django when you add the API path?

  • Load the API through Vite by adding /api/persons/ to the end of your displayed Vite URL.

You should see the person records returned by the Django API. That response proves the frontend address can now reach the backend.

Does the proxy request fail?

  • Confirm the Django terminal still shows the development server running at http://127.0.0.1:8000/.
  • Check that the proxy target in frontend/vite.config.js matches the Django address exactly.
  • Restart Vite after saving the configuration if the frontend terminal was already running.
  • Help me diagnose the Vite proxy connection.

✔️ Awesome, I've got everything!

Your Vite server can reach the Person API through the relative /api path.

ⓧ I'd like to double check the full code

import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:8000",
    },
  },
})
Load people with Axios

React state gives the page somewhere to store API records. The useEffect() hook starts the first request after the component loads.

  • Select frontend/src/App.jsx from the file list on the left side of Visual Studio Code.
  • Replace the starter component with this initial directory shell:
import { useEffect, useState } from "react"
import axios from "axios"

function App() {
  const [persons, setPersons] = useState([])
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(true)

  return (
    <main className="page-shell">
      <header className="hero">
        <h1>Person Directory</h1>
        <p>Add, update, and remove people without refreshing the page.</p>
      </header>

      {error && <p className="message error">{error}</p>}

      <section className="directory" aria-live="polite">
        <div className="section-heading">
          <h2>People</h2>
          <span>{persons.length} records</span>
        </div>
        {loading && <p>Loading people...</p>}
        {!loading && persons.length === 0 && <p>No people yet. Add the first record above.</p>}
      </section>
    </main>
  )
}

export default App

What does this component set up?

  • The persons state starts as an empty array that can hold API records.
  • The loading state controls the message shown while the request is running.
  • The error state gives failed requests a visible place on the page.
  • The record count reads the current length of the persons array.
  • Save frontend/src/App.jsx.
  • Refresh your displayed Vite URL in the browser.

You should see the Person Directory heading with Loading people... beneath the record count.

Does the starter page still appear?

  • Confirm you replaced frontend/src/App.jsx inside the frontend folder.
  • Check the frontend terminal for a syntax message that names a line in App.jsx.
  • Help me fix the React directory shell.
  • Add the request error formatter below the imports in frontend/src/App.jsx by pasting:
function formatError(error) {
  const details = error.response?.data
  return details ? JSON.stringify(details) : "The request could not be completed."
}

What does the error formatter do?

  • The details variable reads any validation response returned by the API.
  • The fallback message covers request failures without response data.
  • Add the loader below the loading state inside App() by pasting:
  async function loadPersons() {
    try {
      const response = await axios.get("/api/persons/")
      setPersons(response.data)
      setError("")
    } catch (requestError) {
      setError(formatError(requestError))
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    loadPersons()
  }, [])

How does the request reach the page?

  • The axios.get() request uses the proxied /api/persons/ path.
  • The response data moves into persons through setPersons().
  • The finally block clears the loading state after either outcome.
  • The empty dependency array runs the effect after the component first appears.
  • Save frontend/src/App.jsx.
  • Refresh your displayed Vite URL.

The loading message should disappear. The record count should now match the number of people stored through the browsable API.

Does the count stay at zero?

  • Confirm at least one Person record still appears at http://127.0.0.1:8000/api/persons/.
  • Check that axios.get("/api/persons/") includes both the opening slash and trailing slash.
  • Help me trace the missing Person records.
  • Add the card grid below the empty-state line inside the directory section by pasting:
        <div className="card-grid">
          {persons.map((person) => (
            <article className="person-card" key={person.id}>
              <img className="avatar" src={person.image} alt={`${person.name} portrait`} />
              <div className="card-content">
                <h3>{person.name}</h3>
                <p>{person.age} years old</p>
                <p>{person.place}</p>
              </div>
            </article>
          ))}
        </div>

How does the card grid work?

  • The map() call creates one card for every object in persons.
  • The record id gives React a stable key for each card.
  • The serializer's image URL becomes the src value for the portrait.
  • The remaining fields place the person's name, age, and location inside the card.
  • Save frontend/src/App.jsx.
  • Refresh your displayed Vite URL.

Your stored person now appears with their portrait, name, age, and place. The API data has become a user-facing directory.

Is the card or portrait missing?

  • Check the browser error message for request details returned by Django.
  • Confirm the record returned by the API contains a value for image.
  • Verify the uploaded file remains beneath backend/media/people/.
  • Help me debug the missing person card.

✔️ Awesome, I've got everything!

Great work. Save frontend/src/App.jsx before styling the directory.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react"
import axios from "axios"

function formatError(error) {
  const details = error.response?.data
  return details ? JSON.stringify(details) : "The request could not be completed."
}

function App() {
  const [persons, setPersons] = useState([])
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(true)

  async function loadPersons() {
    try {
      const response = await axios.get("/api/persons/")
      setPersons(response.data)
      setError("")
    } catch (requestError) {
      setError(formatError(requestError))
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    loadPersons()
  }, [])

  return (
    <main className="page-shell">
      <header className="hero">
        <h1>Person Directory</h1>
        <p>Add, update, and remove people without refreshing the page.</p>
      </header>

      {error && <p className="message error">{error}</p>}

      <section className="directory" aria-live="polite">
        <div className="section-heading">
          <h2>People</h2>
          <span>{persons.length} records</span>
        </div>
        {loading && <p>Loading people...</p>}
        {!loading && persons.length === 0 && <p>No people yet. Add the first record above.</p>}
        <div className="card-grid">
          {persons.map((person) => (
            <article className="person-card" key={person.id}>
              <img className="avatar" src={person.image} alt={`${person.name} portrait`} />
              <div className="card-content">
                <h3>{person.name}</h3>
                <p>{person.age} years old</p>
                <p>{person.place}</p>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  )
}

export default App
Style and verify the directory

The page now has the right data structure. CSS turns those raw elements into a responsive card layout with consistent portrait dimensions.

  • Select frontend/index.html from the file list on the left side of Visual Studio Code.
  • Set the text inside the existing title element to Person Directory.
  • Save frontend/index.html.
  • Check that the browser tab now reads Person Directory.
  • Select frontend/src/index.css from the file list on the left side of Visual Studio Code.
  • Replace the starter styles with these page foundations:
:root {
  font-family: Inter, system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

What do these foundations control?

  • The root styles set the page font, text colour, and background colour.
  • The universal selector includes borders and padding inside each element's declared size.
  • The body fills the browser height while preserving a usable minimum width.
  • Save frontend/src/index.css.
  • Refresh the browser page.

You should see a pale page background with dark text. The starter stylesheet no longer controls the layout.

Do the starter styles remain?

  • Confirm frontend/src/main.jsx still imports ./index.css.
  • Check that you replaced the stylesheet inside frontend/src/.
  • Help me trace the active stylesheet.
  • Add the centered page shell and hero spacing below the body styles:
.page-shell {
  width: min(1100px, 92%);
  margin: 0 auto;
  padding: 2.5rem 0 4rem;
}

.hero {
  margin-bottom: 1.5rem;
}

.hero h1,
.hero p {
  margin: 0.35rem 0;
}

How does the page shell help?

  • The page-shell width leaves room at the sides on narrow screens.
  • The automatic margins centre the directory on wider screens.
  • The hero rules keep the heading compact above the records.
  • Save frontend/src/index.css.
  • Refresh the browser page.

The directory should now sit inside a centred column. The heading should have tighter vertical spacing.

Does the content still touch the edges?

  • Check that the outer element in App.jsx uses className="page-shell".
  • Confirm the selector begins with .page-shell in index.css.
  • Help me fix the centred page layout.
  • Add the shared card surface and error-message styles below the hero rules:
.panel,
.person-card {
  border: 1px solid #dfe5ef;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 12px 32px rgba(27, 39, 66, 0.08);
}

.panel {
  padding: 1.25rem;
  margin-bottom: 2rem;
}

.message {
  border-radius: 0.65rem;
  padding: 0.8rem;
  overflow-wrap: anywhere;
}

.error {
  color: #8a1c2d;
  background: #fdebed;
}

What do the surface styles add?

  • Each person card receives a white background with a border and soft shadow.
  • The rounded corners separate each record from the page background.
  • Request errors receive a readable red treatment that can wrap long response details.
  • Save frontend/src/index.css.
  • Refresh the browser page.

Each person record should now look like a white card with rounded corners. A soft shadow should lift it from the page background.

Do the cards stay transparent?

  • Confirm each mapped record uses className="person-card".
  • Check that the CSS selector includes the leading dot before person-card.
  • Help me connect the card class to its styles.
  • Add the responsive grid rules below the message styles:
.section-heading {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 1rem;
}

.person-card {
  overflow: hidden;
}

How does the grid respond?

  • The section heading places the title and record count on opposite sides.
  • The grid creates as many columns as the available width can hold.
  • Each column stays at least 230px wide before the layout wraps.
  • The card hides image edges that extend beyond its rounded corners.
  • Save frontend/src/index.css.
  • Resize the browser window.

You should see the cards move into fewer columns as the browser narrows. The record count should remain aligned with the People heading.

Does the grid stay in one column?

  • Confirm the card wrapper uses className="card-grid".
  • Check the grid-template-columns value for missing parentheses.
  • Help me repair the responsive card grid.
  • Add the portrait and card-content styles below the grid rules:
.avatar {
  display: block;
  width: 100%;
  height: 220px;
  object-fit: cover;
  background: #e8edf5;
}

.card-content {
  padding: 1rem;
}

.card-content h3,
.card-content p {
  margin: 0 0 0.55rem;
}

How are the portraits controlled?

  • Every portrait fills the card width with a consistent height of 220px.
  • The object-fit rule crops images without stretching them.
  • The card content receives spacing around the name, age, and place.
  • Save frontend/src/index.css.
  • Refresh the browser page.

The portrait should fill the top of its card without distortion. The person's details should sit inside a padded section below it.

Does the portrait look stretched?

  • Confirm the image element uses className="avatar".
  • Check that object-fit: cover appears inside the .avatar selector.
  • Help me correct the portrait dimensions.

✔️ Awesome, I've got everything!

Your saved stylesheet now presents the API records as responsive person cards.

ⓧ I'd like to double check the full code

:root {
  font-family: Inter, system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

.page-shell {
  width: min(1100px, 92%);
  margin: 0 auto;
  padding: 2.5rem 0 4rem;
}

.hero {
  margin-bottom: 1.5rem;
}

.hero h1,
.hero p {
  margin: 0.35rem 0;
}

.panel,
.person-card {
  border: 1px solid #dfe5ef;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 12px 32px rgba(27, 39, 66, 0.08);
}

.panel {
  padding: 1.25rem;
  margin-bottom: 2rem;
}

.message {
  border-radius: 0.65rem;
  padding: 0.8rem;
  overflow-wrap: anywhere;
}

.error {
  color: #8a1c2d;
  background: #fdebed;
}

.section-heading {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 1rem;
}

.person-card {
  overflow: hidden;
}

.avatar {
  display: block;
  width: 100%;
  height: 220px;
  object-fit: cover;
  background: #e8edf5;
}

.card-content {
  padding: 1rem;
}

.card-content h3,
.card-content p {
  margin: 0 0 0.55rem;
}

Before the final refresh, how many cards do you expect to see based on the records in your browsable API?

  • Return to the backend terminal from the previous step.
  • Confirm Django remains available at http://127.0.0.1:8000/.
  • Return to the frontend terminal.
  • Confirm Vite remains available at your displayed Vite URL.
  • Refresh your displayed Vite URL in the browser.

You should see the person created through the browsable API as a responsive card. Its portrait, name, age, and place should match the stored record.

Your backend records now have a visible React interface. Next, you'll add the form that begins the create flow.

Experience the JSON Upload Limit

Your React directory can now turn an API record into a visible person card. The next user-facing capability is collecting a new record from the page.

A selected image begins as a browser File. This step tests whether Axios can send that file inside a normal JSON request that Django REST Framework accepts.

In this step, get ready to:
  • Create controlled form state for the Person fields.
  • Store a selected portrait as a File in form.image.
  • Submit the state object directly to expose the JSON upload limit.
Create controlled form state

A controlled form keeps each current input value inside component state. The image field stores the selected File alongside the text values.

  • In Visual Studio Code, select frontend/src/App.jsx from the Explorer sidebar.
  • Locate the axios import near the top of the file.
  • Add the initial form values below the imports by pasting this code:
const emptyForm = {
  name: "",
  age: "",
  place: "",
  image: null,
}

What Does This State Shape Do?

  • The text properties begin as empty strings so each text input has a defined value.
  • The image property begins as null because the learner has not selected a file.
  • Resetting the form later can restore this same object.
  • Save frontend/src/App.jsx.
  • Check the Vite terminal for a completed module update.

The module should compile without an error. Your existing person cards should remain visible at the Vite development URL.

Seeing a Compilation Error?

  • Check that emptyForm sits outside the App() function.
  • Confirm every property except the last one ends with a comma.
  • Ask for help with the state object: Help me fix the emptyForm object in my React App.jsx file.
  • Locate the persons state declaration inside App().
  • Add the form state declarations directly below it by pasting this code:
const [form, setForm] = useState(emptyForm)
const [message, setMessage] = useState("")

What Do These Values Hold?

  • The form object holds the current field values.
  • The setForm function updates the object whenever an input changes.
  • The message value provides a place for successful request feedback.
  • Save frontend/src/App.jsx.
  • Check the Vite terminal for another completed module update.

The page should compile without a red error overlay. This confirms that the component can initialize the new form state.

Seeing a Hook Error?

  • Keep both state declarations inside the App() function.
  • Confirm the existing import still includes useState.
  • Ask for help with the declarations: Help me debug the form state in App.jsx.
  • Locate the existing useEffect() block.
  • Add the input change handler below it by pasting this code:
function handleChange(event) {
  const { name, value, files } = event.target
  setForm((current) => ({
    ...current,
    [name]: files ? files[0] ?? null : value,
  }))
}

How Does the Change Handler Work?

  • The input's name selects the matching property in form.
  • Text inputs contribute their current value.
  • The file input contributes its first selected File through files[0].
  • The spread expression preserves every field that the current input did not change.
  • Save frontend/src/App.jsx.
  • Check the Vite terminal for a successful module update.

The application should compile successfully. The new handler is ready to receive input events from the form.

Seeing an Error in handleChange?

  • Check that handleChange sits inside App().
  • Confirm the computed property uses square brackets around name.
  • Ask for help with the handler: Help me debug my controlled input handler.
  • Add the temporary submit handler below handleChange() by pasting this code:
async function handleSubmit(event) {
  event.preventDefault()
  setMessage("")
  setError("")

  try {
    await axios.post("/api/persons/", form)
    setMessage("Person added successfully.")
  } catch (requestError) {
    setError(formatError(requestError))
  }
}

What Does the Temporary Submission Do?

  • The event.preventDefault() call keeps the browser on the current page.
  • The axios.post() call sends the current form object directly to the collection endpoint.
  • The catch block passes the API response to the existing formatError() helper.
  • This direct request gives you a concrete test of how the selected File travels inside an ordinary object.
  • Save frontend/src/App.jsx.
  • Check the Vite terminal for a successful module update.

The application should compile without an error. The submit handler is now waiting for a form event.

Seeing an Error in handleSubmit?

  • Confirm handleSubmit sits inside App().
  • Check that the existing Axios import remains at the top of frontend/src/App.jsx.
  • Ask for help with the request: Help me debug my temporary Axios submit handler.
Render the upload form

The handlers need controlled inputs to produce their events. Each input uses its name to update the matching property in form.

  • Locate the existing <section className="directory" element inside the returned JSX.
  • Insert the form section directly above it by pasting this code:
<section className="panel">
  <h2>Add a person</h2>
  <form className="person-form" onSubmit={handleSubmit}>
    <label>
      Name
      <input name="name" value={form.name} onChange={handleChange} required />
    </label>
    <label>
      Age
      <input name="age" type="number" value={form.age} onChange={handleChange} required />
    </label>
    <label>
      Place
      <input name="place" value={form.place} onChange={handleChange} required />
    </label>
    <label>
      Image
      <input name="image" type="file" accept="image/*" onChange={handleChange} required />
    </label>
    <button type="submit">Add person</button>
  </form>
  {message && <p className="message success">{message}</p>}
  {error && <p className="message error">{error}</p>}
</section>

How Is the Form Controlled?

  • Each text input reads its current value from form.
  • Every input calls handleChange when its value changes.
  • The file input accepts image selections through accept="image/*".
  • Submitting the form calls handleSubmit.
  • Save frontend/src/App.jsx.
  • Return to the page at the Vite development URL.

You should see an Add a person panel above the existing card grid. The panel should contain three text fields plus one image picker.

Form Missing from the Page?

  • Confirm the new panel sits inside <main className="page-shell">.
  • Place the panel before the existing directory section.
  • Ask for help with the JSX: Help me fix the upload form in App.jsx.

The form works best as a compact grid with clear feedback beneath it. These styles keep the inputs readable across desktop and narrow screens.

  • Select frontend/src/index.css from the Explorer sidebar.
  • Locate the existing .section-heading block.
  • Add the form layout styles directly above it by pasting this code:
.person-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
}

.person-form label {
  display: grid;
  gap: 0.4rem;
  font-weight: 650;
}

.person-form input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 0.65rem;
  padding: 0.72rem;
  background: white;
}

@media (max-width: 650px) {
  .person-form {
    grid-template-columns: 1fr;
  }
}

What Do These Form Styles Do?

  • The form uses two equal columns on wider screens.
  • Each label keeps its text directly above its input.
  • The media query switches the form to one column on screens narrower than 650px.
  • Save frontend/src/index.css.
  • Return to the Vite page.

You should see the text fields arranged in two columns on a wide browser window. A narrow window should show one input per row.

Form Still Looks Unstyled?

  • Confirm the form uses className="person-form".
  • Check that the styles sit outside every earlier CSS selector block.
  • Ask for help with the layout: Help me debug the person-form CSS.
Test the JSON submission

The request body format determines how the API interprets every field. You can now submit the direct state object and inspect the response from the upload endpoint.

  • Add the feedback styles above the existing .section-heading block in frontend/src/index.css by pasting this code:
.message {
  border-radius: 0.65rem;
  padding: 0.8rem;
  overflow-wrap: anywhere;
}

.success {
  color: #17603a;
  background: #e8f7ee;
}

.error {
  color: #8a1c2d;
  background: #fdebed;
}

How Does Request Feedback Appear?

  • The shared message class adds spacing around request feedback.
  • The success class gives successful feedback a green treatment.
  • The error class gives rejected responses a red treatment.
  • Long response details wrap inside the panel instead of overflowing.
  • Save frontend/src/index.css.
  • Return to the form at the Vite development URL.
  • Enter Morgan Lee in the Name field.
  • Enter 31 in the Age field.
  • Enter Wellington in the Place field.
  • Select a valid image from your computer in the Image field.

Before you submit, do you think the selected File will reach the API as a valid image inside the direct state object?

  • Click Add person.

You should see an HTTP 400 validation response containing an image entry. The selected File did not arrive as a valid uploaded image.

  • Switch back to the Django terminal from earlier.

The Django terminal should show the rejected POST request. This confirms that the request reached the API before validation stopped it.

You found the upload boundary on purpose. The page now exposes the exact validation response that the working request format must resolve.

Why Did the File Fail?

The browser stores the portrait as a File object. Normal JSON serialization does not deliver that object as an uploaded file.

Django REST Framework receives the text fields through the JSON body. Its image field rejects the missing valid upload.

Do You See a Different Result?

  • Confirm handleSubmit passes form directly to axios.post().
  • Confirm the selected file appears in the browser's Image field before submitting.
  • Check that Django remains running at http://127.0.0.1:8000/.
  • Ask for help with the result: Help me reproduce the JSON image upload validation failure.

✔️ Awesome, I've got everything!

Great. Double check that you saved frontend/src/App.jsx plus frontend/src/index.css.

ⓧ I'd like to double check the full code

Compare your temporary JSON version of frontend/src/App.jsx with this file:

import { useEffect, useState } from "react"
import axios from "axios"

const emptyForm = {
  name: "",
  age: "",
  place: "",
  image: null,
}

function formatError(error) {
  const details = error.response?.data
  return details ? JSON.stringify(details) : "The request could not be completed."
}

function App() {
  const [persons, setPersons] = useState([])
  const [form, setForm] = useState(emptyForm)
  const [message, setMessage] = useState("")
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(true)

  async function loadPersons() {
    try {
      const response = await axios.get("/api/persons/")
      setPersons(response.data)
      setError("")
    } catch (requestError) {
      setError(formatError(requestError))
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    loadPersons()
  }, [])

  function handleChange(event) {
    const { name, value, files } = event.target
    setForm((current) => ({
      ...current,
      [name]: files ? files[0] ?? null : value,
    }))
  }

  async function handleSubmit(event) {
    event.preventDefault()
    setMessage("")
    setError("")

    try {
      await axios.post("/api/persons/", form)
      setMessage("Person added successfully.")
    } catch (requestError) {
      setError(formatError(requestError))
    }
  }

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Django REST Framework + React</p>
        <h1>Person Directory</h1>
        <p>Add, update, and remove people without refreshing the page.</p>
      </header>

      <section className="panel">
        <h2>Add a person</h2>
        <form className="person-form" onSubmit={handleSubmit}>
          <label>
            Name
            <input name="name" value={form.name} onChange={handleChange} required />
          </label>
          <label>
            Age
            <input
              name="age"
              type="number"
              value={form.age}
              onChange={handleChange}
              required
            />
          </label>
          <label>
            Place
            <input name="place" value={form.place} onChange={handleChange} required />
          </label>
          <label>
            Image
            <input
              name="image"
              type="file"
              accept="image/*"
              onChange={handleChange}
              required
            />
          </label>
          <button type="submit">Add person</button>
        </form>
        {message && <p className="message success">{message}</p>}
        {error && <p className="message error">{error}</p>}
      </section>

      <section className="directory" aria-live="polite">
        <div className="section-heading">
          <h2>People</h2>
          <span>{persons.length} records</span>
        </div>
        {loading && <p>Loading people...</p>}
        {!loading && persons.length === 0 && <p>No people yet. Add the first record above.</p>}
        <div className="card-grid">
          {persons.map((person) => (
            <article className="person-card" key={person.id}>
              <img className="avatar" src={person.image} alt={`${person.name} portrait`} />
              <div className="card-content">
                <h3>{person.name}</h3>
                <p>{person.age} years old</p>
                <p>{person.place}</p>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  )
}

export default App

Compare your cumulative frontend/src/index.css with this file:

:root {
  font-family: Inter, system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input {
  font: inherit;
}

button {
  border: 0;
  border-radius: 0.65rem;
  padding: 0.7rem 1rem;
  color: white;
  background: #315efb;
  cursor: pointer;
}

button:hover {
  filter: brightness(0.94);
}

.page-shell {
  width: min(1100px, 92%);
  margin: 0 auto;
  padding: 2.5rem 0 4rem;
}

.hero {
  margin-bottom: 1.5rem;
}

.hero h1,
.hero p {
  margin: 0.35rem 0;
}

.eyebrow {
  color: #315efb;
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.panel,
.person-card {
  border: 1px solid #dfe5ef;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 12px 32px rgba(27, 39, 66, 0.08);
}

.panel {
  padding: 1.25rem;
  margin-bottom: 2rem;
}

.panel h2 {
  margin-top: 0;
}

.person-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
}

.person-form label {
  display: grid;
  gap: 0.4rem;
  font-weight: 650;
}

.person-form input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 0.65rem;
  padding: 0.72rem;
  background: white;
}

.message {
  border-radius: 0.65rem;
  padding: 0.8rem;
  overflow-wrap: anywhere;
}

.success {
  color: #17603a;
  background: #e8f7ee;
}

.error {
  color: #8a1c2d;
  background: #fdebed;
}

.section-heading {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 1rem;
}

.person-card {
  overflow: hidden;
}

.avatar {
  display: block;
  width: 100%;
  height: 220px;
  object-fit: cover;
  background: #e8edf5;
}

.card-content {
  padding: 1rem;
}

.card-content h3,
.card-content p {
  margin: 0 0 0.55rem;
}

@media (max-width: 650px) {
  .person-form {
    grid-template-columns: 1fr;
  }
}

The upload limit is now visible in the browser plus the Django terminal. Next up, you'll turn the same form into a working upload flow.

Finish Multipart CRUD

Your React directory can load people from the API. The failed image submission from the previous step exposed the limit of a normal JSON request.

This step replaces that request with multipart form data. You will use Axios to create records with images. You will also add editing and deletion through the same Django REST Framework API.

In this step, get ready to:
  • Replace JSON submission with multipart form data.
  • Add edit and delete controls to each person card.
  • Verify every CRUD action through the interface.
Send multipart create and update requests

A browser FormData object can carry text values plus an image file in one multipart request. The browser supplies the request boundary when Axios sends this object.

  • In the Visual Studio Code Explorer sidebar, select frontend/src/App.jsx.
  • Find the existing form state declaration.
  • Replace that declaration with these three state declarations:
  const [form, setForm] = useState(emptyForm)
  const [editingId, setEditingId] = useState(null)
  const [fileInputKey, setFileInputKey] = useState(0)

What Does This State Track?

  • The form state continues to hold the controlled text values plus the selected image file.
  • The editingId state identifies the record that receives a partial update.
  • The fileInputKey state gives React a fresh file input after the form resets.
  • Find the end of the existing handleChange function.
  • Add the form reset helper directly below it:
  function resetForm() {
    setForm(emptyForm)
    setEditingId(null)
    setFileInputKey((current) => current + 1)
  }

How Does the Reset Work?

The helper clears every controlled field. It also leaves edit mode and recreates the file input so the previous file selection disappears.

  • Save frontend/src/App.jsx.
  • Refresh the Vite page in your browser.

You should still see the Person Directory with its existing records. This confirms the new state and reset helper compile successfully.

Does the Page Stop Rendering?

  • Check that all three state declarations remain inside the App function.
  • Confirm that resetForm closes before the next function begins.
  • Ask for help with the new state declarations and reset helper.
  • Find the existing handleSubmit function that sends form directly through axios.post().
  • Replace the complete function with this multipart version:
  async function handleSubmit(event) {
    event.preventDefault()
    setMessage("")
    setError("")

    const payload = new FormData()
    payload.append("name", form.name)
    payload.append("age", form.age)
    payload.append("place", form.place)
    if (form.image) {
      payload.append("image", form.image)
    }

    try {
      if (editingId) {
        await axios.patch(`/api/persons/${editingId}/`, payload)
        setMessage("Person updated successfully.")
      } else {
        await axios.post("/api/persons/", payload)
        setMessage("Person added successfully.")
      }
      resetForm()
      await loadPersons()
    } catch (requestError) {
      setError(formatError(requestError))
    }
  }

What Does This Function Do?

  • The payload stores each text value as a multipart field.
  • The image is appended only when the learner selected a file. This lets an edit keep the existing portrait.
  • The editingId value chooses between axios.post() for creation and axios.patch() for an edit.
  • The browser sets Content-Type with the required multipart boundary. The request does not set that header manually.
  • A successful write resets the form. It then reloads the directory from the API.
  • Save frontend/src/App.jsx.
  • Predict whether the same selected image now reaches the API successfully.
  • Enter a new name in the form.
  • Enter an age in the form.
  • Enter a place in the form.
  • Choose an image file.
  • Click Add person.

You should see Person added successfully. below the form. The new person card should appear with its uploaded portrait without a manual page refresh.

Does the Upload Still Fail?

  • Confirm that axios.post() receives payload instead of form.
  • Check that the image append line sits inside the if (form.image) block.
  • Ask for help with the multipart request.
Add edit and delete controls

The detail URL identifies one person by ID. A partial update can change selected fields while deletion removes the matching database record.

  • Add the startEdit function directly below resetForm in frontend/src/App.jsx.
  function startEdit(person) {
    setForm({
      name: person.name,
      age: String(person.age),
      place: person.place,
      image: null,
    })
    setEditingId(person.id)
    setFileInputKey((current) => current + 1)
    setMessage(`Editing ${person.name}`)
    setError("")
  }

How Does Edit Mode Start?

The function copies the selected person's text fields into the controlled form. It keeps the image value empty so the current portrait remains optional during the edit.

  • Add the deletePerson function directly below handleSubmit.
  async function deletePerson(person) {
    setMessage("")
    setError("")
    try {
      await axios.delete(`/api/persons/${person.id}/`)
      if (editingId === person.id) {
        resetForm()
      }
      setMessage(`${person.name} was deleted.`)
      await loadPersons()
    } catch (requestError) {
      setError(formatError(requestError))
    }
  }

How Does Deletion Stay in Sync?

The detail request removes the selected record. The directory then reloads from the API so the deleted card disappears immediately.

Deleting the record currently being edited also clears the form. This prevents the interface from keeping a stale edit target.

  • Save frontend/src/App.jsx.
  • Refresh the Vite page.

You should still see the directory and its current cards. This confirms both request functions compile before you connect them to buttons.

  • Inside the person card mapping, find the paragraph that displays person.place.
  • Add the card controls directly below that paragraph:
                <div className="card-actions">
                  <button type="button" onClick={() => startEdit(person)}>
                    Edit
                  </button>
                  <button
                    className="danger"
                    type="button"
                    onClick={() => deletePerson(person)}
                  >
                    Delete
                  </button>
                </div>

How Do the Card Controls Work?

Each callback receives the person represented by that card. The edit button loads that object into the form while the delete button passes its ID to the detail request.

  • Save frontend/src/App.jsx.
  • Refresh the Vite page.
  • Click Edit on an existing person card.

The selected person's name, age, and place should appear in the form. The message below the form should identify the person being edited.

  • Find the form panel heading.
  • Replace its static text with this conditional heading:
        <h2>{editingId ? "Edit person" : "Add a person"}</h2>

What Changes the Heading?

A stored person ID changes the heading to edit mode. A cleared ID returns the form to create mode.

  • Find the existing Image label and file input.
  • Replace that section with this edit-aware version:
          <label>
            Image
            <input
              key={fileInputKey}
              name="image"
              type="file"
              accept="image/*"
              onChange={handleChange}
              required={!editingId}
            />
          </label>
          {editingId && <p className="hint">Leave the image blank to keep the current one.</p>}

Why Is the Image Optional During Edits?

New records still require a portrait. Edit mode allows an empty file input because the multipart payload omits the image field when no replacement was selected.

  • Save frontend/src/App.jsx.
  • Refresh the Vite page.
  • Click Edit on a person card.

You should see Edit person above the form. You should also see the hint explaining that the existing image can remain unchanged.

  • Find the current submit button inside the form.
  • Replace it with these edit-aware form controls:
          <div className="form-actions">
            <button type="submit">{editingId ? "Save changes" : "Add person"}</button>
            {editingId && (
              <button className="secondary" type="button" onClick={resetForm}>
                Cancel
              </button>
            )}
          </div>

How Do the Form Controls Change?

The primary button follows the current mode. The cancel button appears only during editing and returns the form to its empty create state.

  • Save frontend/src/App.jsx.
  • Refresh the Vite page.
  • Click Edit on a person card.
  • Click Cancel.

The form should return to Add a person with empty fields. The selected card should remain unchanged.

Do the Edit Controls Misbehave?

  • Confirm that every Edit button passes its own person object to startEdit.
  • Check that the file input uses required={!editingId}.
  • Ask for help with edit mode or deletion.

✔️ Awesome, I've got everything!

Great. Double check that you saved frontend/src/App.jsx.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react"
import axios from "axios"

const emptyForm = {
  name: "",
  age: "",
  place: "",
  image: null,
}

function formatError(error) {
  const details = error.response?.data
  return details ? JSON.stringify(details) : "The request could not be completed."
}

function App() {
  const [persons, setPersons] = useState([])
  const [form, setForm] = useState(emptyForm)
  const [editingId, setEditingId] = useState(null)
  const [fileInputKey, setFileInputKey] = useState(0)
  const [message, setMessage] = useState("")
  const [error, setError] = useState("")
  const [loading, setLoading] = useState(true)

  async function loadPersons() {
    try {
      const response = await axios.get("/api/persons/")
      setPersons(response.data)
      setError("")
    } catch (requestError) {
      setError(formatError(requestError))
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    loadPersons()
  }, [])

  function handleChange(event) {
    const { name, value, files } = event.target
    setForm((current) => ({
      ...current,
      [name]: files ? files[0] ?? null : value,
    }))
  }

  function resetForm() {
    setForm(emptyForm)
    setEditingId(null)
    setFileInputKey((current) => current + 1)
  }

  function startEdit(person) {
    setForm({
      name: person.name,
      age: String(person.age),
      place: person.place,
      image: null,
    })
    setEditingId(person.id)
    setFileInputKey((current) => current + 1)
    setMessage(`Editing ${person.name}`)
    setError("")
  }

  async function handleSubmit(event) {
    event.preventDefault()
    setMessage("")
    setError("")

    const payload = new FormData()
    payload.append("name", form.name)
    payload.append("age", form.age)
    payload.append("place", form.place)
    if (form.image) {
      payload.append("image", form.image)
    }

    try {
      if (editingId) {
        await axios.patch(`/api/persons/${editingId}/`, payload)
        setMessage("Person updated successfully.")
      } else {
        await axios.post("/api/persons/", payload)
        setMessage("Person added successfully.")
      }
      resetForm()
      await loadPersons()
    } catch (requestError) {
      setError(formatError(requestError))
    }
  }

  async function deletePerson(person) {
    setMessage("")
    setError("")
    try {
      await axios.delete(`/api/persons/${person.id}/`)
      if (editingId === person.id) {
        resetForm()
      }
      setMessage(`${person.name} was deleted.`)
      await loadPersons()
    } catch (requestError) {
      setError(formatError(requestError))
    }
  }

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Django REST Framework + React</p>
        <h1>Person Directory</h1>
        <p>Add, update, and remove people without refreshing the page.</p>
      </header>

      <section className="panel">
        <h2>{editingId ? "Edit person" : "Add a person"}</h2>
        <form className="person-form" onSubmit={handleSubmit}>
          <label>
            Name
            <input name="name" value={form.name} onChange={handleChange} required />
          </label>
          <label>
            Age
            <input
              name="age"
              type="number"
              value={form.age}
              onChange={handleChange}
              required
            />
          </label>
          <label>
            Place
            <input name="place" value={form.place} onChange={handleChange} required />
          </label>
          <label>
            Image
            <input
              key={fileInputKey}
              name="image"
              type="file"
              accept="image/*"
              onChange={handleChange}
              required={!editingId}
            />
          </label>
          {editingId && <p className="hint">Leave the image blank to keep the current one.</p>}
          <div className="form-actions">
            <button type="submit">{editingId ? "Save changes" : "Add person"}</button>
            {editingId && (
              <button className="secondary" type="button" onClick={resetForm}>
                Cancel
              </button>
            )}
          </div>
        </form>
        {message && <p className="message success">{message}</p>}
        {error && <p className="message error">{error}</p>}
      </section>

      <section className="directory" aria-live="polite">
        <div className="section-heading">
          <h2>People</h2>
          <span>{persons.length} records</span>
        </div>
        {loading && <p>Loading people...</p>}
        {!loading && persons.length === 0 && <p>No people yet. Add the first record above.</p>}
        <div className="card-grid">
          {persons.map((person) => (
            <article className="person-card" key={person.id}>
              <img className="avatar" src={person.image} alt={`${person.name} portrait`} />
              <div className="card-content">
                <h3>{person.name}</h3>
                <p>{person.age} years old</p>
                <p>{person.place}</p>
                <div className="card-actions">
                  <button type="button" onClick={() => startEdit(person)}>
                    Edit
                  </button>
                  <button
                    className="danger"
                    type="button"
                    onClick={() => deletePerson(person)}
                  >
                    Delete
                  </button>
                </div>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  )
}

export default App
Verify CRUD and the production build

The finished interface now uses the same API resource for every operation. This final check proves that create, read, update, and delete stay synchronized with the database.

  • Return to the Person Directory page served by Vite.
  • Add a new person with a portrait.

The new card should appear with its image. The record count should increase without a manual refresh.

  • Click Edit on the new card.
  • Change the person's place.
  • Leave the image input empty.
  • Predict whether the existing portrait stays in place after this edit.
  • Click Save changes.

You should see Person updated successfully. below the form. The card should show the new place while keeping its original portrait.

  • Click Edit on the same card.
  • Choose a different image.
  • Click Save changes.

The card should now display the replacement portrait. The updated record remains visible without a manual page refresh.

  • Click Delete on the updated card.

The card should disappear immediately. You should see a deletion message while the record count decreases.

  • Switch back to the frontend terminal from earlier.
  • Press Ctrl+C to stop the Vite development server.
  • Predict whether the production build accepts the same CRUD code.
  • Build the frontend by running this command:
npm run build

What Should You See?

Vite should complete the production build successfully. This confirms the final React code can be bundled without syntax or module errors.

Does the Build Fail?

  • Check the first file and line reported by the build output.
  • Compare frontend/src/App.jsx with the full-code tab above.
  • Ask for help with the production build.
  • Restart the Vite development server by running:
npm run dev

What Should You See?

Vite should print its local development URL again. Opening that URL should return you to the working Person Directory.

You did it. Your directory now creates image-backed records, updates text or portraits, and removes records through one responsive interface.

Secret mission

Preview Images Before Upload

A selected portrait currently stays hidden until the upload finishes. Add an instant preview and reject non-image files before they reach the API.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project stays on your Windows PC, so there are no ongoing charges or cloud resources to manage. Choose whether to keep both local servers running, pause them while preserving your files, or remove the entire project.

Resources you used:

  • The person-directory folder. It contains the .venv virtual environment, requirements.txt, frontend/node_modules, application source code, and other project files.
  • The local SQLite database at backend/db.sqlite3. It contains your persisted Person records.
  • The applied migration at backend/people/migrations/0001_initial.py plus the uploaded portraits beneath backend/media/people/.
  • The Django development server process if it is still running.
  • The Vite development server process if it is still running.

Why Uploaded Images Can Remain

Deleting a Person record from the app does not automatically remove its portrait file from backend/media/people/. Deleting the whole person-directory folder clears those leftover files.

Keep everything running

No action is needed. Choose this if you are still testing the Person Directory or plan to extend it.

  • Leave the Django development server terminal open.
  • Leave the Vite development server terminal open.
  • Keep the person-directory folder in its current location.

Pause - I'll come back to this later

Stop both development servers to free up their terminals. Your project files, records, and uploaded portraits remain available for your next session.

  • Return to the terminal running the Django server.
  • Press Ctrl+C to stop the Django server.
  • Return to the terminal running the Vite server.
  • Press Ctrl+C to stop the Vite server.

Your development servers are safely paused. Your records remain in backend/db.sqlite3. Your portraits remain beneath backend/media/people/.

Delete - I don't want to use this again

Deleting the project folder is the full reset. The deletion affects the local person-directory folder while leaving your other files untouched.

  • Return to the terminal running the Django server.
  • Press Ctrl+C to stop the Django server.
  • Return to the terminal running the Vite server.
  • Press Ctrl+C to stop the Vite server.

Both development processes are now stopped. Every remaining project resource is stored inside person-directory.

  • Press the Windows key to open search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Navigate to the folder that contains person-directory.
  • Select the person-directory folder.
  • Press the Delete key.

You should no longer see person-directory in its previous location. Windows has moved it to the Recycle Bin.

  • Return to your Windows desktop.
  • Right-click Recycle Bin.
  • Select Empty Recycle Bin.
  • Confirm the permanent removal when Windows asks.

Cleanup is complete. Your virtual environment, dependencies, database, migrations, source code, and uploaded portraits have been removed.

Nice Work!

Nice Work!

You did it! Your local Person Directory now manages image-backed records through a React interface backed by a Django REST Framework API.

You've learned how to:

  • Build a Django model for image-backed Person records persisted through SQLite migrations.
  • Expose RESTful CRUD behavior through a Django REST Framework viewset registered with a router.
  • Connect React state to multipart form data sent with Axios through a Vite development proxy.
  • Complete the optional Secret Mission by pairing an instant FileReader image preview with client-side MIME type validation.

Ready to quiz yourself?