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([])34 return (5 <>6 <h1>Put your pictures here.</h1>7 <p>This is important...</p>8 <ImageUploader9 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}2021export 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;56 &:hover {7 cursor: pointer;8 background-color: rgba(52, 254, 172, 0.5);9 }10`1112const 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([])34 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 <ImageUploader13 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 }45 const submitPictures = () => {6 uploadedPictures.map((picture) => {7 const reader = new FileReader()89 reader.readAsDataURL(picture[0])1011 reader.onload = function () {12 const base64Url = reader.result13 }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 String4 file_name String5}
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 }78 type Query {9 pictures: [Picture!]!10 picture(id: Int!): Picture11 }1213 input CreatePictureInput {14 file: String!15 file_name: String!16 }1718 input UpdatePictureInput {19 file: String20 file_name: String21 }2223 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'23export const pictures = () => {4 return db.picture.findMany()5}67export const picture = ({ id }) => {8 return db.picture.findUnique({9 where: { id },10 })11}1213export const createPicture = ({ input }) => {14 return db.picture.create({15 data: input,16 })17}1819export const updatePicture = ({ id, input }) => {20 return db.picture.update({21 data: input,22 where: { id },23 })24}2526export 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 id5 file6 file_name7 }8 }9`10const CREATE_PICTURE = gql`11 mutation createPicture($file: String, $file_name: String) {12 createPicture(input: { file: $file, file_name: $file_name }) {13 id14 file_name15 }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()45 reader.readAsDataURL(picture[0])67 reader.onload = function () {8 const base64Url = reader.result910 create({ variables: { file: base64Url, file_name: picture[0].name } })1112 location.reload()13 }14 })15 }
Quick check
This is what your HomePage
component should look like now.
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`45const 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.