Rails API & React Frontend for Image Uploads to Amazon S3
Goal
To spin up a new Rails API with a React frontend that allows for a single image upload that is sent back to the Rails API for processing with storage on Amazon S3.
Initial Setup
> mkdir image_upload_example && cd image_upload_example
# Rails API Setup - Rails 6.1
> rails new backend --api --database=postgresql && cd backend && bin/setup && cd ..
# React Frontend Setup
> yarn create react-app frontend --template typescript
Now that we have a base set for our frontend and backend let’s get a Procfile
set up that will allow us to boot these two application together. I like using the Procfile
in conjunction with
overmind, I highly recommend it.
Procfile
In the root of your entire project create a new file Procfile
and add the following.
backend: cd backend && bin/rails server
frontend: cd frontend && yarn start
From theis same directory you can now run: overmind start
and it will go boot both applications and apply a default port of 5000
to the Rails API and 5100
to the React frontend.
Rails API Backend Setup
We are going to use Active Storage to facilitate the uploading and storage of files to Amazon S3.
# From the backend directory
> bin/rails active_storage:install && bin/rails db:migrate
Next we will want to set up Active Storage to talk with Amazon S3.
In config/storage.yml
we will want to declare an Amazon service. It is alrerady defined in this file you will need to uncomment it.
...
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: your_region
bucket: your_own_bucket
...
Then in config/environments/development.rb
we need to tell Active Storage which service to use. For the purposes of this demo and what I wanted to see locally I set this to the amazon
service instead of the local
disk storage.
# Change the following line
config.active_storage.service = :local
# to
config.active_storage.service = :amazon
You will also need to add, gem "aws-sdk-s3", require: false
to your Gemfile
.
Another gem to install while in there is the rack-cors
. It is already listed in the Gemfile
you will need to uncomment it.
For the sake of this demo we will take the easiest route to get cors
setup to allow the React frontend talk to the backend.
In config/initializers/corrs.rb
you can uncomment the code there to make it look like the below.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
We are going to now add a very basic model and controller for Recipes
.
> bin/rails g model Recipe name:string --no-test-framework
> bin/rails g controller recipes --no-helper --no-assets --no-test-framework
> bin/rake db:migrate
To get an image attached to this new Recipe object we need to add has_one_attached :image
class Recipe < ApplicationRecord
has_one_attached :image
end
In addition to adding the image attribute to the model we need to modify the controller to create the recipe and allow an image attribute through the strong params.
class RecipesController < ApplicationController
def create
recipe = Recipe.create!(recipe_params)
end
private
def recipe_params
params.require(:recipe).permit(:name, :image)
end
end
React Frontend Setup
First let’s install a few dependancies we want to use in this demo.
# Formik for default form behavior
> yarn add formik
# React Dropzone for image uploading
> yarn add react-dropzone
# Styled Components for some base styling of React Dropzone
> yarn add styled-components @types/styled-components
For this we are going to take over the default App
page and put all the functionality there.
The App.tsx
will look something like below:
import React, { useState, useCallback } from "react";
import { Formik, Field, Form, FormikHelpers } from "formik";
import { DropzoneRootProps, FileWithPath, useDropzone } from "react-dropzone";
import styled from "styled-components";
interface Values {
name: string;
image?: FileWithPath;
}
const getColor = (props: DropzoneRootProps) => {
if (props.isDragAccept) {
return "#00e676";
}
if (props.isDragReject) {
return "#ff1744";
}
if (props.isDragActive) {
return "#2196f3";
}
return "#eeeeee";
};
const AppContainer = styled.div`
margin: 50px;
`;
const FieldContainer = styled.div`
margin: 5px;
`;
const Container = styled.div`
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-width: 2px;
border-radius: 2px;
border-color: ${(props) => getColor(props)};
border-style: dashed;
background-color: #fafafa;
color: #bdbdbd;
outline: none;
transition: border 0.24s ease-in-out;
`;
const ImageContainer = styled.img`
max-width: 200px;
max-height: 200px;
`;
const App = () => {
const [imagePreview, setImagePreview] = useState('');
const [file, setFile] = useState('');
const onDrop = useCallback((acceptedFile) => {
setFile(acceptedFile[0]);
setImagePreview(URL.createObjectURL(acceptedFile[0]));
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
multiple: false,
accept: "image/*",
onDrop,
});
const preview = (
<div>
<ImageContainer src={imagePreview} alt="recipe" />
</div>
);
const onSubmit = (
values: Values,
{ setSubmitting }: FormikHelpers<Values>
) => {
let formData = new FormData();
formData.append("recipe[name]", values.name);
formData.append("recipe[image]", file);
const requestOptions = {
method: "POST",
body: formData,
};
fetch("http://localhost:5000/recipes", requestOptions);
};
return (
<AppContainer>
<h1>Homebrew Recipe</h1>
<Formik
initialValues={{
name: "",
}}
onSubmit={onSubmit}
>
{({ setFieldValue }) => (
<Form>
<FieldContainer>
<label htmlFor="name">Name</label>
<Field id="name" name="name" placeholder="IPA" />
</FieldContainer>
<div className="container">
<Container
{...getRootProps({
isDragActive,
isDragAccept,
isDragReject,
})}
>
<input {...getInputProps()} />
<p>Drag 'n' drop an image here, or click to select an image</p>
</Container>
{imagePreview && (
<div>
<h4>Image Preview</h4>
{preview}
</div>
)}
</div>
<button type="submit">Submit</button>
</Form>
)}
</Formik>
</AppContainer>
);
};
export default App;
A main point to notice in the above code is that with Formik and React Dropzone during the onSubmit
you have to take the collected values and create a FormData
object to get the image sent back to the backend.
Here is a minimal working version getting an image uploaded from the frontend to the backend to aws.