This covers the two parts people usually get stuck on separately: showing the image in the browser before it's uploaded (pure JavaScript, no server round-trip needed), and actually saving the file through an AJAX request without a full page reload.
1. The form
<form id="avatar-form">
@csrf
<input type="file" name="avatar" id="avatar-input" accept="image/*">
<img id="avatar-preview" src="#" alt="" style="display:none; max-width:200px;">
<button type="submit">Upload</button>
</form>
2. Live preview with FileReader
This part never touches the server — it reads the file the browser already has selected:
document.getElementById('avatar-input').addEventListener('change', function (e) {
const file = e.target.files[0];
if (! file) return;
const reader = new FileReader();
reader.onload = function (event) {
const preview = document.getElementById('avatar-preview');
preview.src = event.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
});
3. Submitting via AJAX with FormData
File inputs can't be serialized as JSON — FormData is required for a multipart upload:
document.getElementById('avatar-form').addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/profile/avatar', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: formData,
})
.then(res => res.json())
.then(data => console.log('Uploaded:', data.url))
.catch(err => console.error(err));
});
Note there's no manual Content-Type header set — the browser needs to generate its own multipart boundary string, which it can only do if you let it set the header itself.
4. The controller
public function store(Request $request)
{
$request->validate([
'avatar' => ['required', 'image', 'max:4096'],
]);
$path = $request->file('avatar')->store('avatars', 'public');
$request->user()->update(['avatar_path' => $path]);
return response()->json([
'url' => Storage::disk('public')->url($path),
]);
}
Make sure php artisan storage:link has been run at least once so public/storage resolves to storage/app/public — without it, the returned URL will 404 even though the file uploaded successfully.