Handling image uploads with PostgreSQL

Milecia

Intro

There are a lot of new JAMStack frameworks that are appearing on the scene. You have NextJS, Eleventy, React Static, and a bunch of others. One thing that's missing from these frameworks is the ability to work with databases in your web app.

That's where RedwoodJS comes in. It's a framework that lets you do full-stack JAMStack development. It uses React, GraphQL, and Prisma. One of the cool things about this framework is that if you have an existing React project, it's not terribly complicated to port it over.

This tutorial is just going to give you an idea for one of the things you can do with a full-stack JAMStack application. We're going to upload images submitted by users to a PostgreSQL database. We'll go over how to set up your RedwoodJS app and start accepting images from users and displaying them back.

You can clone a working version of this from Github or just follow along with the tutorial.

Creating a RedwoodJS app

Create a new directory for your app called redwood, and go to that folder in a terminal. Then, run the following command.

yarn create redwood-app ./redwood-image-uploader

This will start a chain of events that creates the skeleton for your entire project, including the back-end. Once the setup process is finished, go to the redwood-image-uploader directory in your terminal.

You'll notice a number of directories and files were created. The api folder is where all of the Primsa and GraphQL files live. The web directory is where the React code lives.

Redwood handles a lot of things behind the scenes for you, but they keep things clear enough to find the right files to edit if you want to do things manually. If you want to learn more about the details, make sure to go check out their docs.

From here, we can actually start our app with the following command.

yarn rw dev

You'll see the following page once it's running.

Now that the Redwood app is up and running, we can start working on the front-end code.

Adding an image uploader

To get started, we're going to install a few packages in the web directory. We're going to install the styled-components and react-images-upload packages. So make sure you're in the web directory in your terminal, and then run the following.

yarn add styled-components react-images-upload

This gives us an upload component so we don't have to spend a lot of time handling states and styling. Next, go to the root directory, and run the following.

yarn rw generate page home /

This creates a bunch of new files that automatically route to a template home page. We'll be adding all of our code to this HomePage.js file. You can delete all of the existing code in the file, and start fresh by adding a few imports.

Step 1

1import { useState } from 'react'
2import ImageUploader from 'react-images-upload'
3import styled from 'styled-components'

Next we'll start on our HomePage component.

Step 2

In our component, we start by creating a new state that will hold the information for the images we want to upload. Then, we return a view with a few components, the main one being the ImageUploader.

The props we pass to it let us display the icon to a user, show a preview of the image when it's uploaded, set some restrictions on files, and start the process of uploading images. The prop we'll pay the most attention to is what we have in onChange.

1const HomePage = () => {
2 const [uploadedPictures, setUploadedPictures] = useState([])
3
4 return (
5 <>
6 <h1>Put your pictures here.</h1>
7 <p>This is important...</p>
8 <ImageUploader
9 withIcon={true}
10 withPreview={true}
11 buttonText="Choose images"
12 onChange={(image) => onDrop(image)}
13 singleImage={true}
14 imgExtension={['.jpg', '.gif', '.png', '.gif']}
15 maxFileSize={5242880}
16 />
17 </>
18 )
19}
20
21export default HomePage

If you run the project again with yarn rw dev in the root directory, you should see this when the page loads.

Step 3

Now, we'll do a little layout clean up with a few styled components. These have CSS that we need to apply to our component.

1const Button = styled.button`
2 background-color: #34feac;
3 padding: 10px 12px;
4 border-radius: 20px;
5
6 &:hover {
7 cursor: pointer;
8 background-color: rgba(52, 254, 172, 0.5);
9 }
10`
11
12const Container = styled.div`
13 margin: auto;
14 width: 500px;
15`

Step 4

Here's what the updated component should look like. This adds some formatting to the uploader, but the main thing it adds is a submit button for users to save their images in the PostgreSQL database.

1const HomePage = () => {
2 const [uploadedPictures, setUploadedPictures] = useState([])
3
4 return (
5 <>
6 <h1>Put your pictures here.</h1>
7 <p>This is important...</p>
8 {uploadedPictures.length !== 0 && (
9 <Button onClick={submitPictures}>Save your pictures now</Button>
10 )}
11 <Container>
12 <ImageUploader
13 withIcon={true}
14 withPreview={true}
15 buttonText="Choose images"
16 onChange={(image) => onDrop(image)}
17 singleImage={true}
18 imgExtension={['.jpg', '.gif', '.png', '.gif']}
19 maxFileSize={5242880}
20 />
21 </Container>
22 </>
23 )
24}

Now, we'll add the functions that are used in the different components.

Step 5

The onDrop function is how we will store the information for the images we want to upload to the database. It sets the state to an array of images.

The submitPictures function is where we're actually going to upload the image data to PostgreSQL. There are still a few back-end things we need to set up, but this has most of the functionality in place.

When a user submits images to be uploaded to the database, we take all of the images in the uploadedPictures state, create new FileReader objects for them, and upload the image as a base64 string.

1const onDrop = (picture) => {
2 setUploadedPictures([...uploadedPictures, picture])
3 }
4
5 const submitPictures = () => {
6 uploadedPictures.map((picture) => {
7 const reader = new FileReader()
8
9 reader.readAsDataURL(picture[0])
10
11 reader.onload = function () {
12 const base64Url = reader.result
13 }
14 })
15 }

Now that we have our front-end mostly finished, let's switch to some back-end business.

Setting up PostgreSQL

If you don't have PostgreSQL installed locally, you can download it for free here: https://www.postgresql.org/download/ This will also work if you have an instance of PostgreSQL in the cloud.

In the api directory, update the schema.prisma to use postgresql instead of sqlite for the provider. Then, you can delete the UserExample model, and add the following.

1model Picture {
2 id Int @id @default(autoincrement())
3 file String
4 file_name String
5}

You'll need to rename the .env.example file in the root directory to .env. Then edit it to uncomment the DATABASE_URL. This is where you put your connection string to the PostgreSQL database. It might look something like this: postgres://postgres:admin@localhost:5432/pictures

Now, you'll need to run a migration to get the table in place in PostgreSQL. Do that with the following command in the root directory.

yarn rw prisma migrate dev

You'll need to write a name for the migration at some point in the process, so make sure it describes what you're migrating. Once your migration is finished, you can go to the pgAdmin for your PostgreSQL instance, and you should see something similar to this.

Working with GraphQL

We have our front-end and database set up, so now we need to handle the back-end part with GraphQL. This is something else that Redwood makes super fast to do. If you run yarn rw g sdl pictures --crud, it will generate all of the GraphQL types, queries, and mutations based the pictures schema you defined in the schema.prisma file.

You can take a look at the generated files in the api/src/graphql and api/src/services directories. The graphql directory has all of your types defined, and the services directory has all of your mutations and queries defined. Now, all we have to do is add these methods to the front-end and we'll be finished!

First, we'll go through each of the autogenerated files just so you know where everything is defined.

pictures.sdl.js

This file was autogenerated as part of the yarn rw g sdl pictures --crud command. This is the GraphQL schema that is based on the model we defined in the schema.prisma file. Redwood creates all of the types we need to do all of the basic CRUD operations, but we'll be focusing on the create (mutation) and read (query) operations.

1export const schema = gql`
2 type Picture {
3 id: Int!
4 file: String!
5 file_name: String!
6 }
7
8 type Query {
9 pictures: [Picture!]!
10 picture(id: Int!): Picture
11 }
12
13 input CreatePictureInput {
14 file: String!
15 file_name: String!
16 }
17
18 input UpdatePictureInput {
19 file: String
20 file_name: String
21 }
22
23 type Mutation {
24 createPicture(input: CreatePictureInput!): Picture!
25 updatePicture(id: Int!, input: UpdatePictureInput!): Picture!
26 deletePicture(id: Int!): Picture!
27 }
28`

pictures.js

Another nice thing Redwood does for us is autogenerate all of the GraphQL resolvers for the CRUD operations. These connect directly to the PostgreSQL instance you defined in the .env.

1import { db } from 'src/lib/db'
2
3export const pictures = () => {
4 return db.picture.findMany()
5}
6
7export const picture = ({ id }) => {
8 return db.picture.findUnique({
9 where: { id },
10 })
11}
12
13export const createPicture = ({ input }) => {
14 return db.picture.create({
15 data: input,
16 })
17}
18
19export const updatePicture = ({ id, input }) => {
20 return db.picture.update({
21 data: input,
22 where: { id },
23 })
24}
25
26export const deletePicture = ({ id }) => {
27 return db.picture.delete({
28 where: { id },
29 })
30}

Finishing the front-end

Back in our HomePage component, we need to add the calls to GraphQL that will let us save images uploaded by users, and reload them on the page. To do that, we'll start by adding the following import statement.

Step 1

1import { useMutation, useQuery } from '@redwoodjs/web'

Next we need to define how we want the data returned from GraphQL. We're using the GraphQL language to define our query and how we want the picture data returned with GET_PICTURE.

In CREATE_PICTURE, we're defining how we should pass data to the database, and what values should be returned after a successful creation.

Step 2

1const GET_PICTURE = gql`
2 query {
3 pictures {
4 id
5 file
6 file_name
7 }
8 }
9`
10const CREATE_PICTURE = gql`
11 mutation createPicture($file: String, $file_name: String) {
12 createPicture(input: { file: $file, file_name: $file_name }) {
13 id
14 file_name
15 }
16 }
17`

Step 3

Finally, we'll use our imported methods to help us work with these GraphQL queries. This goes inside of the HomePage component below our state.

The create method is what we'll be using to upload pictures to the database and the data object will give us the information we need to display saved images to users.

1const [create] = useMutation(CREATE_PICTURE)
2const { data } = useQuery(GET_PICTURE)

Now we need to update the submitPictures function to use the create method. When a user clicks this button now, it will upload their image to the database in base64 format and reload the page to display the saved images.

1const submitPictures = () => {
2 uploadedPictures.map((picture) => {
3 const reader = new FileReader()
4
5 reader.readAsDataURL(picture[0])
6
7 reader.onload = function () {
8 const base64Url = reader.result
9
10 create({ variables: { file: base64Url, file_name: picture[0].name } })
11
12 location.reload()
13 }
14 })
15 }

Quick check

This is what your HomePage component should look like now.

GitHub Gist with updated code

Cleaning things up

The last thing we need to do is show saved images to users. We'll do that by using the data object. We'll create a couple of styled components to make things a little clearer in the view.

1const Flex = styled.div`
2 display: flex;
3`
4
5const Img = styled.img`
6 padding: 24px;
7 height: 200px;
8 width: 200px;
9`

Now we can loop through the saved images, and display them with the following addition to the component.

1<Flex>
2 {data?.pictures &&
3 data.pictures.map((picture) => <Img src={picture.file} />)}
4 </Flex>

Conclusion

We're done! Now, when a user uploads an image through this interface, you'll be able to store and retrieve it from a Postgres database. Hopefully this has given you an idea of how to work with a full-stack JAMStack app using Redwood. There are a number of other ways you can handle images as well. You could upload them to S3 buckets, or use a service like Cloudinary.

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.