Handling file uploads from a client side application (e.g. an Ionic application) to a backend server (e.g. Node/Express/NestJS) is quite different to using POST
requests to send text data. It may look quite similar on the front end, as a file
input looks more or less the same as any other HTML input:
<inputtype="file"/>
You might expect that you could just POST
this data using a standard HTTP request to a server and retrieve the file in the same way that you would retrieve any other value from a form.
However, this is not how file uploads works. As you can probably imagine, sending text values to a server like a username
or password
is quite quick/easy and would be instantly available for the server to access. A file could be arbitrarily large, and if we want to send a 3GB video along with our POST
request then it is going to take some time for all of those bytes to be sent over the network.
In this tutorial, we will be using an Ionic application as the front end client, and a Node/Express server as the backend to demonstrate these concepts. I have also published another tutorial that covers using NestJS to handle file uploads on the backend. Although we are using a specific tech stack here, the basic concepts covered apply quite generally in other contexts.
NOTE: This tutorial will be focusing on uploading files through a standard file
input on the web. We will be covering handling native file uploads (e.g. from the users Photo gallery in an Ionic application that is running natively on iOS/Android) in another tutorial.
Not interested in the theory? Jump straight to the example. This tutorial will include examples for Ionic/StencilJS, Ionic/Angular, and Ionic/React. You can also watch the video version of this tutorial below:
The Role of Multipart Form Data
If you are using a standard HTML form tag to capture input and POST
it to a server (which we won’t be doing) then you will need to set its enctype
(encoding type) to multipart/form-data
:
<formaction="http://localhost:3000/upload"method="post"enctype="multipart/form-data"><inputtype="file"name="photo"/></form>
This is just one way to encode the form data that is to be sent off to some server. The default encoding type for a form is application/x-www-form-urlencoded
but if we want to upload a file using the file
input type then we need to set the enctype
to multipart/form-data
. This encoding type is not as efficient as x-www-form-urlencoded
but if we use multipart/form-data
then it won’t encode characters which means the files being uploaded won’t have their data corrupted by the encoding process.
Using the Form Data API
As I mentioned, we are not going to be using a standard HTML <form>
with an action
and enctype
. This is common in the context of Ionic/Angular/React/StencilJS applications as we commonly implement our own form logic and handle firing off our own HTTP requests to submit form data (rather than setting the action
of the form and having the user click a <input type="submit">
button).
Since we are just using form input elements as a way to capture data, rather than using an HTML form to actually submit the data for us, we need a way to send that captured data along with the HTTP request we trigger at some point. This is easy enough with simple text data, as we can just attach it directly to the body manually, e.g:
const data ={
comment:'hello',
author:'josh'};let response =awaitfetch("https://someapi.com/comments",{
method:'POST',
body:JSON.stringify(data),
headers:{'Content-Type':'application/json'}});
In this scenario, we could just replace hello
and josh
with whatever data the user entered into the form inputs (exactly how this is achieved will depend on the framework being used).
If you would like more information on sending POST
requests with the Fetch API you can read: HTTP Requests in StencilJS with the Fetch API. This is a good option if you are using StencilJS or React, but if you are using Angular you would be better off using the built-in HttpClient
.
But how do we handle adding files to the body of the HTTP request?
We can’t just add files to the body of the request as we would with simple text values. This is where the FormData API comes in. The FormData API
allows us to dynamically create form data that we can send via an HTTP request, without actually needing to use an HTML <form>
. The best part is that the form data created will be encoded the same way as if we had used a form with an enctype
of multipart/form-data
which is exactly what we want to upload files.
All you would need to do is listen for a change event on the file input fields, e.g. in StencilJS/React:
<inputtype="file"onChange={ev=>this.onFileChange(ev)}></input>
or Angular:
<inputtype="file"(change)="onFileChange($event)"/>
and then pass that event to some method that will make the data available to whatever is building your form data for submission (either immediately or later). With StencilJS this would look like:
uploadPhoto(fileChangeEvent){// Get a reference to the file that has just been added to the inputconst photo = fileChangeEvent.target.files[0];// Create a form data object using the FormData APIlet formData =newFormData();// Add the file that was just added to the form data
formData.append("photo", photo, photo.name);// POST formData to server using Fetch API}
or with React:
constuploadPhoto=(fileChangeEvent)=>{// Get a reference to the file that has just been added to the inputconst photo = fileChangeEvent.target.files[0];// Create a form data object using the FormData APIlet formData =newFormData();// Add the file that was just added to the form data
formData.append("photo", photo, photo.name);// POST formData to server using Fetch API };
or with Angular:
uploadPhoto(fileChangeEvent){// Get a reference to the file that has just been added to the inputconst photo = fileChangeEvent.target.files[0];// Create a form data object using the FormData APIlet formData =newFormData();// Add the file that was just added to the form data
formData.append("photo", photo, photo.name);// POST formData to server using HttpClient}
If you didn’t want to submit the form immediately after detecting the change
event, you could store the value of fileChangeEvent.target.files[0]
somewhere until you are ready to use it (e.g. in a member variable in Angular or StencilJS, or with a useRef
in React). Keep in mind that you do specifically need to store the result of a change/submit event to get a reference to the File
, attempting to get the current value of the form control when you need to use it (as you would with other standard <input>
fields) won’t work, it will just return:
C:\fakepath\
Which is a security feature implemented by browsers to prevent the filesystem structure of the users machine being exposed through JavaScript.
Using Multer to Handle File Uploads
We have an idea of how to send a file from the front end to a backend server now, but what do we do with it when it gets there? If we were using POST
to send standard text data to a Node/Express server we might just set up a POST
endpoint and get a reference to the data through the requests body
object, e.g:
app.post('/upload',(req, res)=>{
console.log(req.body.photo);});
This won’t work for our file input.
We need to stream that data over time to our backend, such that there is continuous communication happening between our local client side application and the backend server until the upload is finished. Remember, we might be trying to upload a 3GB video file, and that is going to take a little while.
Exactly how file uploads are handled will depend on what backend you are using, but Express does not handle file uploads by default. Therefore, we need to use some kind of additional library/middleware to handle our multipart/form-data
that is being sent from our client side application to the server.
One way to do this is to use busboy which is able to parse incoming multipart/form-data
, but it is also somewhat complex. We can simplify things by using multer which basically sits on top of busboy and handles the more complex aspects for us.
Multer will handle the streams of data provided by busboy for us, and automatically upload the file to a specified destination on the server. If you want a buffer stream from multer
instead of storing the file on the system, you can also use the memory storage option that multer
provides (the uploaded file will be stored in memory for you to do with as you please, rather than being written to a file).
We will be using multer
directly in the example for our Node/Express server, but multer
is also what NestJS uses behind the scenes to handle file uploads (we will cover that in the NestJS tutorial). We will walk through a complete implementation of this backend in a moment, but this is what the basic usage of multer
looks like.
const express =require('express')const multer =require('multer')const upload =multer({ dest:'uploads/'})const app =express()
app.post('/upload', upload.single('photo'),(req, res)=>{
console.log(req.file);});
We just specify a destination folder for where the files should be uploaded, and specify the name of the file field being uploaded in upload.single('photo')
. Now when the data is sent via POST
request to the /upload
route the file will automatically be uploaded into the uploads
directory.
You can still send other standard form data (e.g. text inputs) along with your file upload as well, and this will be available in the body
of the request, i.e: req.body
.
Now let’s walk through building a practical example with Ionic and Node/Express. This walk-through will assume that you have some basic knowledge of both Ionic and Node/Express.
1. Create a new Node/Express server
Create a folder to contain your server and then run npm init
inside of it (you can just keep the default options if you wish), then install the following packages:
npm install express cors body-parser multer morgan
2. Create the index.js file
Now we will create the index.js
file inside of our server folder to define the upload
route that we want to POST
data to:
const express =require("express");const cors =require("cors");const bodyParser =require("body-parser");const morgan =require("morgan");const multer =require("multer");const upload =multer({ dest:"uploads/"});const app =express();
app.use(cors());
app.use(morgan("combined"));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended:true}));
app.post("/upload", upload.single("photo"),(req, res)=>{
console.log(req.file);});const port = process.env.PORT||3000;
app.listen(port,()=>{
console.log("Server running...");});
Note that we have specified the uploads/
directory in our multer
configuration - this is where the files will be uploaded. You do not need to create this manually, it will be created automatically by multer
when you start your server if it does not exist already.
We are using the most simplistic setup for multer here, just keep in mind that there are further configurations that you can make - check out the documentation for multer.
In order to receive file uploads from the front end application we are about to create, make sure that you run your server with:
node index.js
3. POST a File to the Backend
Now we need to allow the user to select a file using an <input type="file">
form input, use that file to build our FormData
, and then POST
that data to our backend server.
The following is an example of doing this in an Ionic/StencilJS application, but as we have been discussing you can use this same basic concept elsewhere:
import{ Component, h }from'@stencil/core';
@Component({
tag:'app-home',
styleUrl:'app-home.css',})exportclassAppHome{private file: File;onFileChange(fileChangeEvent){this.file = fileChangeEvent.target.files[0];}asyncsubmitForm(){let formData =newFormData();
formData.append('photo',this.file,this.file.name);try{const response =awaitfetch('http://localhost:3000/upload',{
method:'POST',
body: formData,});if(!response.ok){thrownewError(response.statusText);}
console.log(response);}catch(err){
console.log(err);}}render(){return[<ion-header><ion-toolbarcolor="primary"><ion-title>Home</ion-title></ion-toolbar></ion-header>,<ion-contentclass="ion-padding"><ion-item><ion-label>Image</ion-label><inputtype="file"onChange={ev=>this.onFileChange(ev)}></input></ion-item><ion-buttoncolor="primary"expand="full"onClick={()=>this.submitForm()}>
Upload
</ion-button></ion-content>,];}}
The solution will look almost identical in Ionic/React:
import{
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
IonItem,
IonLabel,
IonButton,}from"@ionic/react";import React,{ useRef }from"react";import"./Home.css";interfaceInternalValues{
file: any;}const Home: React.FC=()=>{const values = useRef<InternalValues>({
file:false,});
const onFileChange = (fileChangeEvent: any) =>{
values.current.file = fileChangeEvent.target.files[0];};
const submitForm = async () =>{if(!values.current.file){returnfalse;}let formData =newFormData();
formData.append("photo", values.current.file, values.current.file.name);try{const response =awaitfetch("http://localhost:3000/upload",{
method:"POST",
body: formData,});if(!response.ok){thrownewError(response.statusText);}
console.log(response);}catch(err){
console.log(err);}};
return (
<IonPage><IonHeader><IonToolbar><IonTitle>Image Upload</IonTitle></IonToolbar></IonHeader><IonContent><IonItem><inputtype="file"onChange={(ev)=>onFileChange(ev)}></input></IonItem><IonButtoncolor="primary"expand="full"onClick={()=>submitForm()}>
Upload
</IonButton></IonContent></IonPage>
);
};
export default Home;
and for Angular you would need to replace the usage of the fetch
API with HttpClient
instead:
import{ Component }from"@angular/core";import{ HttpClient }from"@angular/common/http";
@Component({
selector:"app-home",
templateUrl:"home.page.html",
styleUrls:["home.page.scss"],})exportclassHomePage{private file: File;constructor(private http: HttpClient){}onFileChange(fileChangeEvent){this.file = fileChangeEvent.target.files[0];}asyncsubmitForm(){let formData =newFormData();
formData.append("photo",this.file,this.file.name);this.http.post("http://localhost:3000/upload", formData).subscribe((response)=>{
console.log(response);});}}
<ion-header><ion-toolbar><ion-title>Image Upload</ion-title></ion-toolbar></ion-header><ion-content><ion-item><inputtype="file"(change)="onFileChange($event)"/></ion-item><ion-buttoncolor="primary"expand="full"(click)="submitForm()">Upload</ion-button></ion-content>
After supplying a file and clicking the Upload Photo
button, you should find that the file has been uploaded to the uploads
folder inside of your Node/Express project. Make sure that you run the server with node index.js
before you attempt to upload the file.
Extension: Handling Multiple File Uploads
The example above will handle uploading an individual file to the backend, but we might also want to upload multiple files at once. Fortunately, multer
also supports uploading an array of files.
If we were using a standard HTML form, then we might specify an array of files likes this:
<formaction="/upload"method="post"enctype="multipart/form-data"><inputtype="file"name="photos[]"/><inputtype="file"name="photos[]"/><inputtype="file"name="photos[]"/></form>
But again, we generally don’t just submit HTML forms in StencilJS/Angular/React applications. Instead, what we could do is listen for the file change events on <input type="file">
elements as we already have been, and whenever a file is uploaded we would append the file to the same array in the form data:
formData.append("photos[]", photo, photo.name);
Handling this with multer
on the backend also only requires a simple change:
app.post("/uploads", upload.array("photos[]"),(req, res)=>{
console.log(req.files);});
I’ve created a new route here called uploads
and the only required change to support multiple file uploads is to call uploads.array
instead of upload.single
and supply photos[]
instead of photo
. Data about the files uploaded will now be on req.files
as well, instead of req.file
.
Let’s take a look at adding these modifications to our example so that we can handle both single and multiple file uploads (the single upload is redundant here, I am just keeping it to show the difference between the two methods):
Backend
const express =require("express");const cors =require("cors");const bodyParser =require("body-parser");const morgan =require("morgan");const multer =require("multer");const upload =multer({ dest:"uploads/"});const app =express();
app.use(cors());
app.use(morgan("combined"));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended:true}));
app.post("/upload", upload.single("photo"),(req, res)=>{
console.log(req.file);});
app.post("/uploads", upload.array("photos[]"),(req, res)=>{
console.log(req.files);});const port = process.env.PORT||3000;
app.listen(port,()=>{
console.log("Server running...");});
Frontend
As the multiple file uploads add quite a bit of bulk to the example, I will just be including the modified StencilJS version of the frontend below. All three (StencilJS, React, and Angular) versions with multiple file uploads will be available in the associated source code for this blog post.
import{ Component, h }from'@stencil/core';
@Component({
tag:'app-home',
styleUrl:'app-home.css',})exportclassAppHome{// Single file uploadprivate file: File;// Multiple file uploadprivate fileOne: File;private fileTwo: File;private fileThree: File;// Single file uploadonSingleFileChange(fileChangeEvent){this.file = fileChangeEvent.target.files[0];}asyncsubmitSingleForm(){let formData =newFormData();
formData.append('photo',this.file,this.file.name);try{const response =awaitfetch('http://localhost:3000/upload',{
method:'POST',
body: formData,});if(!response.ok){thrownewError(response.statusText);}
console.log(response);}catch(err){
console.log(err);}}// Multiple file upload:onFileOneChange(fileChangeEvent){this.fileOne = fileChangeEvent.target.files[0];}onFileTwoChange(fileChangeEvent){this.fileTwo = fileChangeEvent.target.files[0];}onFileThreeChange(fileChangeEvent){this.fileThree = fileChangeEvent.target.files[0];}asyncsubmitMultipleForm(){let formData =newFormData();
formData.append('photos[]',this.fileOne,this.fileOne.name);
formData.append('photos[]',this.fileTwo,this.fileTwo.name);
formData.append('photos[]',this.fileThree,this.fileThree.name);try{const response =awaitfetch('http://localhost:3000/uploads',{
method:'POST',
body: formData,});if(!response.ok){thrownewError(response.statusText);}
console.log(response);}catch(err){
console.log(err);}}render(){return[<ion-header><ion-toolbarcolor="primary"><ion-title>Home</ion-title></ion-toolbar></ion-header>,<ion-contentclass="ion-padding"><ion-item><ion-label>Image</ion-label><inputtype="file"onChange={ev=>this.onSingleFileChange(ev)}></input></ion-item><ion-buttoncolor="primary"expand="full"onClick={()=>this.submitSingleForm()}>
Upload Single
</ion-button><ion-item><ion-label>Images</ion-label><inputtype="file"onChange={ev=>this.onFileOneChange(ev)}></input><inputtype="file"onChange={ev=>this.onFileTwoChange(ev)}></input><inputtype="file"onChange={ev=>this.onFileThreeChange(ev)}></input></ion-item><ion-buttoncolor="primary"expand="full"onClick={()=>this.submitMultipleForm()}>
Upload Multiple
</ion-button></ion-content>,];}}
Summary
Handling file uploads is somewhat tricky business, but the FormData API
and multer
(with the help of busboy
) simplifies things a great deal for us. Your requirements might not always be as easy as simply using the default options for multer
and uploading to a single static directory, but this should serve as a good starting point to dive into the more complex aspects of multer
(or even busboy
if necessary).