Post Cover Image — Uploading with the Post Create/Edit Flow

The post cover image upload has a structural challenge: the post editor needs a cover image URL to include in the post payload, but the URL only exists after the image is uploaded. There are two approaches: upload first (upload the image immediately on selection, get back a URL, then include that URL when creating the post) or upload with the post (send both the image and the metadata in one multipart request). The upload-first approach is simpler on the React side and more flexible — it works with both POST and PATCH without changing the endpoint signature.

Upload-First Approach

// Add to apiSlice.js
uploadPostImage: builder.mutation({
    query: (formData) => ({
        url:      "/posts/upload-image",
        method:   "POST",
        body:     formData,
        formData: true,
    }),
}),
// src/pages/PostEditorPage.jsx (cover image section)
import { useState, useCallback }          from "react";
import { useUploadPostImageMutation }      from "@/store/apiSlice";
import ImagePicker                         from "@/components/ui/ImagePicker";

function CoverImageSection({ coverImageUrl, onChange }) {
    const [uploadImage, { isLoading: isUploading }] = useUploadPostImageMutation();

    // When user selects a file, upload immediately and give parent the URL
    const handleFileSelect = useCallback(async (file) => {
        if (!file) {
            onChange(null);   // user cleared the selection
            return;
        }

        const formData = new FormData();
        formData.append("image", file);

        try {
            const { url } = await uploadImage(formData).unwrap();
            onChange(url);   // pass the server URL to the parent form
        } catch (err) {
            // Upload failed — clear the selection in the picker
            onChange(null);
        }
    }, [uploadImage, onChange]);

    return (
        <div className="space-y-2">
            <label className="block text-sm font-medium">Cover Image</label>

            {/* Show existing cover when editing */}
            {coverImageUrl && !coverImageUrl.startsWith("blob:") && (
                <div className="relative">
                    <img src={coverImageUrl} alt="Cover"
                         className="w-full h-40 object-cover rounded-lg" />
                    <button
                        type="button"
                        onClick={() => onChange(null)}
                        className="absolute top-2 right-2 bg-black/50 text-white
                                   px-2 py-1 text-xs rounded"
                    >
                        Remove
                    </button>
                </div>
            )}

            <ImagePicker
                label={coverImageUrl ? "Change cover" : "Add cover image"}
                onChange={handleFileSelect}
            />

            {isUploading && (
                <p className="text-sm text-blue-600 animate-pulse">Uploading image…</p>
            )}
        </div>
    );
}

// In the main PostEditorPage form:
// const [coverImageUrl, setCoverImageUrl] = useState(existingPost?.cover_image_url ?? null);
// ...
// payload = { ...formValues, cover_image_url: coverImageUrl };
Note: The upload-first approach means an image can be uploaded but never attached to a post — for example, if the user uploads an image then abandons the form. These orphaned images accumulate in your storage over time. Solutions: run a periodic cleanup job that deletes images not referenced by any post (scheduled with Celery or a cron job), store uploaded images with a “pending” flag and only mark them as “permanent” when the post is saved, or use a pre-signed URL approach where the image is only stored for 24 hours unless referenced.
Tip: When editing an existing post, always show the current cover image with a “Remove” button. The form state should distinguish between three cases: (1) existing cover image kept — coverImageUrl = post.cover_image_url, (2) new image uploaded — coverImageUrl = newUrl, (3) cover image removed — coverImageUrl = null. Only include cover_image_url in the PATCH payload when it has actually changed, to avoid unnecessary server-side processing.
Warning: Do not upload the image inside the form’s onSubmit handler. The upload can take several seconds, during which the submit button would appear frozen even though the form data is ready. Upload-first keeps the form submission fast (just JSON) while the image upload happens asynchronously as soon as the user selects it. If the form is submitted before the image finishes uploading, disable the submit button while isUploading is true.

Disabling Submit During Image Upload

function PostEditorPage() {
    const [isImageUploading, setIsImageUploading] = useState(false);
    const [form, setForm] = useState({ ...INITIAL_FORM, cover_image_url: null });

    function handleCoverChange(url) {
        setForm((p) => ({ ...p, cover_image_url: url }));
    }

    return (
        <form onSubmit={handleSubmit} noValidate>
            {/* ... other fields ... */}
            <CoverImageSection
                coverImageUrl={form.cover_image_url}
                onChange={handleCoverChange}
                onUploadingChange={setIsImageUploading}
            />
            <button
                type="submit"
                disabled={isSubmitting || isImageUploading}
            >
                {isImageUploading ? "Waiting for image…"
                : isSubmitting    ? "Saving…"
                : "Publish Post"}
            </button>
        </form>
    );
}

Common Mistakes

Mistake 1 — Uploading in onSubmit (slow, blocking UX)

❌ Wrong — entire submit is blocked while image uploads:

async function handleSubmit(formValues) {
    const { url } = await uploadImage(formData).unwrap();   // user waits here!
    await createPost({ ...formValues, cover_image_url: url });
}

✅ Correct — upload on file selection, save the URL in state, submit only metadata.

Mistake 2 — Not disabling submit while image uploads

❌ Wrong — user submits with cover_image_url: null while image is uploading:

✅ Correct — pass disabled={isSubmitting || isImageUploading} to the submit button.

🧠 Test Yourself

A user selects a cover image, waits for it to upload, then clicks Publish. Two weeks later the image URL returns 404. What likely happened?