Build a simple file uploader with Django (and Alpine.js) πŸ”οΈπŸŒ…

Photo of Tom Dekan
by Tom Dekan

Many Django tutorials don't get file uploads right.

Even the official docs β€” typically very good β€” demonstrate server-side uploads. This is bad for a live application: without extra technologies, your server will freeze if users upload multiple large files server-side at the same time.

Instead, the effective way is to upload files straight from the user's web browser (client-side) to your file hosting service, such as AWS S3.

I've shown how to do this with HTMX before.

This guide uses Alpine.js and is even faster to add. I challenge you to complete the guide in under 4 minutes and 30 seconds πŸ‡

Optional video tutorial (featuring me πŸ‡πŸΏ) is here:

0. Setup

Create your Django app: - Install Django and create a new Django app
pip install django boto3 python-dotenv
django-admin startproject core .
python manage.py startapp sim
- Register your new app by adding it to your `INSTALLED_APPS` in `settings.py`.
# settings.py

INSTALLED_APPS = [
    ...
    'sim',
    ...
]

1. Connect our file hosting service (AWS S3)

Joke: Why did the file apply for a job at AWS?

It was on its S3 bucket list.

Connecting your AWS account (and S3 bucket πŸͺ£) is fast to do. Each step is very small.

1.1 Create/login account

1.2 Create an IAM user with S3 access

  • Visit the IAM service (Click on the IAM service)
  • Create an IAM user to access your account (IAM > Users > Create user). Use any username.
  • On 'Set Permissions':

    • Click -> 'Attach policies directly'
    • Find and select -> AmazonS3FullAccess (full S3 read/write access to your IAM role).

(Search for AmazonS3FullAccess, rather than scrolling through the 1131 available permissions policies).

set-S3-permissions-for-your-IAM-role

  • Click to 'Create user'

1.3 Get your AWS access keys for your new user

  • Click on your newly created user (Trivia: My user's username is "BananaGod" πŸŒπŸ•Œ).
  • Under, Access keys, click Create access key
  • Click any use case
  • Ignore the Set description tag
  • Click Create access key and leave this page open. We'll need the key shortly.

1.4 Create an S3 bucket to store your uploads

  • Go to the S3 service at https://s3.console.aws.amazon.com/
  • Click the button 'Create bucket'
  • Enter a globally unique name for your bucket.
  • The other settings don't matter for us. Click to 'Create bucket' πŸͺ£

1.5 Update your S3 bucket permissions to allow our upload

  • Go to your new bucket's permission (Click on it -> 'Permissions')
  • Scroll down to the "Cross-origin resource sharing (CORS)" section and click "Edit".
  • Paste the below :
[
    {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
        "AllowedOrigins": ["*"],
        "ExposeHeaders": []
    }
]
  • Save the changes.

1.6 Store your AWS access keys in Django

  • Create a file called .env into your project at core/.env
  • Add your AWS access keys and bucket name. Don't wrap the contents with speech marks.
AWS_ACCESS_KEY_ID=123123-eefse-your-key
AWS_SECRET_ACCESS_KEY=123123-eefse-another-key
BUCKET_NAME=your-bucket-name
  • Click 'Done' on the AWS Access keys page

Note: these keys allow you to access your aws account. Don't upload them to github. If you want to share your repo, add your .env file to a .gitignore file to make git ignore the file with your keys.

1.7 Add your AWS keys to your app

  • Add these lines to the top of yourΒ core/settings.pyΒ to load the AWS keys from yourΒ .envΒ file into your Django app as environment variables when you run your Django server.
from pathlib import Path
from dotenv import load_dotenv
import os

load_dotenv()

# The below are optional checks that we've connected the keys.
if not os.getenv('AWS_ACCESS_KEY_ID'):
    print('Missing your AWS_ACCESS_KEY_ID')
if not os.getenv('AWS_SECRET_ACCESS_KEY'):
    print('Missing your AWS_SECRET_ACCESS_KEY')
if not os.getenv('BUCKET_NAME'):
    print('Missing your BUCKET_NAME')

2. Generate your upload url with Django (aka 'Now the fun begins')

Generate a presigned URL

  • In sim/services.py, create a view to handle generating a presigned URL:
import os
import boto3


def generate_presigned_post(bucket_name, filename, expiration=600):
    """
    Docs: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3/client/generate_presigned_post.html#generate-presigned-post
    """
    s3_client = boto3.client(
        's3',
        aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
        aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY')
    )
    return s3_client.generate_presigned_post(
        Bucket=bucket_name, Key=filename,
        ExpiresIn=expiration,
    )

3. Create a view to render the template

  • In sim/views.py, add:
import json
import os

from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.shortcuts import render
from django.http import JsonResponse
from .services import generate_presigned_post


@method_decorator(csrf_exempt, name='dispatch')
def uploader(request):
    if request.method == 'GET':
        return render(request, 'upload.html')

    elif request.method == 'POST':
        body = json.loads(request.body)
        file = body.get('file')
        if not file:
            return JsonResponse({'error': 'Missing file in request body'}, status=400)

        presigned_data = generate_presigned_post(
            bucket_name=os.getenv('BUCKET_NAME'), filename=file['name']
        )
        return JsonResponse(presigned_data)

Add your urls routing

  • Add the below to core/urls.py:
from django.contrib import admin
from django.urls import include, path


urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('sim.urls')),
]
  • Create sim/urls.py and add:
from django.urls import path
from . import views

urlpatterns = [
    path('uploader/', views.uploader, name='generate_presigned_url'),
]

4. Add your template and Alpine.js file uploader:

  • Create a template folder at sim/templates
  • Add a HTML file at sim/templates/upload.html with the below:
<!DOCTYPE html>
<html lang="en" xmlns:x-on="http://www.w3.org/1999/xhtml" xmlns:x-bind="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Upload</title>
    <script src="//unpkg.com/alpinejs" defer></script>
</head>
<body>

<div x-data="{ file: null, presignedData: null, message: '' }">
    <input type="file" x-on:click="message = ''" x-on:change="getPresignedData">
    <button x-on:click="upload" x-bind:disabled="!presignedData">Upload</button>
    <div x-text="message"></div>

    <script>
        async function getPresignedData(event) {
            /*
            Create a presigned URL for uploading a file to S3.
            */
            const files = event.target.files
            const file = files[0]
            this.file = file
            const response = await fetch('/uploader/', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
                body: JSON.stringify({ file: {name: file.name, content_type: file.type} })
            });
            if (!response.ok) {
                const data = await response.json();
                console.error('Error:', data.error);
            }
            else{
                this.presignedData = await response.json();
            }
        }

        async function upload() {
            /*
            Upload a file to S3 using the presigned URL.
             */
            const formData = new FormData();
            for (const [key, value] of Object.entries(this.presignedData.fields)) {
                formData.append(key, value);
            }
            formData.append('file', this.file);

            const response = await fetch(this.presignedData.url, { method: 'POST', body: formData });
            this.message = response.ok ? 'βœ… Upload successful' : `❌ Upload failed: The error message is ${response.statusText}`;
        }
    </script>
</div>

</body>
</html>

5. Run your Django Server:

Run your Django server locally from your terminal:

python manage.py runserver

  • Navigate to http://127.0.0.1:8000/ in your web browser, select a file, and click the "Upload" button to upload the file directly to your S3 bucket using the presigned URL.
Errors? Some tips here: - If you get a 403 error, check your AWS keys are correct and that your bucket name is correct. - If you get a 400 error, check your CORS settings are correct. - If you get a 500 error, check your IAM user has the correct permissions. - If you get something like "Uncaught TypeError: Failed to fetch at Proxy.upload", try doing a hard refresh (Ctrl + Shift + R on MacOS) to clear your browser cache. (This affe

Your bonus for finishing: Make the frontend elegant πŸŽ‰

  • Update your template with styling:
<!DOCTYPE html>
<html lang="en" xmlns:x-on="http://www.w3.org/1999/xhtml" xmlns:x-bind="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Upload</title>
    <script src="//unpkg.com/alpinejs" defer></script>
    <style>
        body {
            background-color: #fafafa;
            font-family: 'Roboto', sans-serif;
        }

        .uploader {
            padding: 30px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
            width: 300px;
            margin: auto;
            border-radius: 16px;
        }

        .file-input {
            border: 1px solid #e0e0e0;
            padding: 12px;
            border-radius: 8px;
            margin-bottom: 20px;
            width: calc(100% - 24px);
            transition: border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
        }

        .file-input:focus {
            border-color: #007aff;
            box-shadow: 0 0 0 3px rgba(0,122,255, 0.25);
        }

        .upload-button {
            background-color: #007aff;
            padding: 12px 20px;
            border-radius: 8px;
            transition: background-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
            width: 100%;
            border: none;
        }
        .upload-button:hover{
        }

        .upload-button:hover:not(:disabled) {
            background-color: #005bb5;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            color: white
        }

        .upload-button:disabled {
            background-color: #e0e0e0;
        }

        .message {
            margin-top: 20px;
            font-size: 14px;
            color: #2c2c2c;
            text-align: center;
        }

    </style>

</head>
<body>

<div x-data="{ file: null, presignedData: null, message: '' }" class="uploader">
    <input type="file" x-on:click="message = ''" x-on:change="getPresignedData" class="file-input">
    <button x-on:click="upload" x-bind:disabled="!presignedData" class="upload-button">Upload</button>
    <div x-text="message" class="message"></div>

    <script>
        async function getPresignedData(event) {
            /*
            Create a presigned URL for uploading a file to S3.
            */
            const files = event.target.files
            const file = files[0]
            this.file = file
            const response = await fetch('/uploader/', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token }}' },
                body: JSON.stringify({ file: {name: file.name, content_type: file.type} })
            });
            if (!response.ok) {
                const data = await response.json();
                console.error('Error:', data.error);
            }
            else{
                this.presignedData = await response.json();
            }
        }

        async function upload() {
            /*
            Upload a file to S3 using the presigned URL.
             */
            const formData = new FormData();
            for (const [key, value] of Object.entries(this.presignedData.fields)) {
                formData.append(key, value);
            }
            formData.append('file', this.file);

            const response = await fetch(this.presignedData.url, { method: 'POST', body: formData });
            this.message = response.ok ? 'βœ… Upload successful' : `❌ Upload failed: The error message is ${response.statusText}`;
        }
    </script>
</div>

</body>
</html>

P.S Fed up with styling Django templates manually, like a weaver before the automated loom? 🎽

Want to build your Django frontend faster? Probably like you, I'm eager to turn ideas into products asap.

Enter Photon Designer: the visual editor that I'm building to craft Django frontends at light-speed (and much faster than a weaver) πŸ’‘

Let's get visual.

Do you want to create beautiful frontends effortlessly?
Click below to book your spot on our early access mailing list (as well as early adopter prices).
Copied link to clipboard πŸ“‹

Made with care by Tom Dekan

Β© 2024 Photon Designer