This article explains how to build a full-stack application using redwoodjs. we will learn how to build some of the important modules such as authentication, data fetching, and forms in an application using redwoodjs.
Demo
Here, we will be building a simple recipe application which contains Authentiation, Data fetching and Forms in it.
What is RedwoodJS
RedwoodJS brings the style of JAMstack full-stack application development. if you love to build applications in a JAM stack way. you will fall in love with redwoodJS for sure.
Redwood helps you to bring your application to the ground as easily as possible. Most of the time, we spend the time setting up a client/server-side architecture rather than writing our business logic. using redwood, we just need to worry about our application business requirement. it helps us to set up and get started within a few commands.
How it works
Here, we have the client and server-side in a mono-repo folder structure where API stands for backend infrastructure and web stands for the frontend of our application.
Setup
Note: I assume that you have installed yarn or node. if not, install nodejs in your machine and make sure it’s installed properly.
node --version yarn --version
Let’s get started by running the command
yarn create redwood-app ./recipe-app
it creates the redwood application scaffold inside the folder recipe-app with all the boilerplate required to run the application.
you can run the application using the command,
cd recipe-app yarn rw dev
Note: ‘rw’ stands for redwood. you can either use redwood or ‘rw’ in the command line.
Now, that we have a boilerplate. let’s see the business domains of our application.
Domain Model
Here, we have user and Recipe domains. an application user story will be like,
User Story
- Users can log in and Signup into the application and view all the recipes created by different users.
- Users can create a Recipe and publish it for other people it sees.
Now, that we know the functionalities of our application. Let’s start by building the authentication system for our application.
DB Setup
Now, that we have user stories and domain models. let’s create the DB table. redwood exclusively uses Prisma 2.
Note: if you’re new Prisma, check out this article
go to api/prisma/schema.prisma and create the models for User and Recipe and connect Postgres
datasource DS { provider = "postgres" url = "postgresql://postgres:postgres@localhost:5435/postgres" } generator client { provider = "prisma-client-js" binaryTargets = env("BINARY_TARGET") } model User { id Int @id @default(autoincrement()) email String @unique name String password String? Recipe Recipe[] } model Recipe { id Int @id @default(autoincrement()) name String description String imageUrl String likes Int userId Int? User User? @relation(fields: [userId], references: [id]) }
Here, we connect Prisma with Postgres. run the Postgres in your local machine and connect to it using the connection string.
Here, i am using docker to run the Postgres in local machine. you can read more about this in official docs
After that, we create models such as User and Recipe with the required attributes for our Postgres tables.
One important thing to note here is a foreign key reference in the Recipe model. In Prisma, we can declare the reference using @relation field in model along with the fields and references in it.
Prisma relations Docs
Once we complete the Prisma model, we can deploy it using the commands,
yarn rw db save
it saves our changes to the prisma
yarn rw db up
above command, migrates our changes to postgres database.
Authentication
Let’s examine the in-built authentication system that redwood provides us.
https://redwoodjs.com/tutorial/authentication
Redwood provides Auth0 and Netlify Auth out of the box. that’s cool. we don’t need to build one for ourselves. But, still, sometimes, we might need to create our own auth system instead of using the third party.
Actually, there’s a discussion in the redwood community explaining why we don’t need one. checkout the thread.
I would say, I am exploring this part. it is always good to have options in the implementation. so, I wanted to roll-out my own auth system and see if it really works out.
spoiler alert: it was not easy and made only possible by a workaround in the frontend part.
Okay, enough about the talk, let’s see how I built the Auth. let’s start from the backend part.
For API, we need loginUser and createUser in the graphql and graphql resolvers. before creating graphql resolvers in our application, let’s see how redwood structures our backend code.
let’s explore the directory structure one by one and see the functionalities of it.
- functions – it contains serverless functions that we need to run to.
- graphql – it contains all the graphql sdl files of our business domains. for our application, it will contain sdl for user and recipe.
- lib – we can add the utility functions here. as of now, it contains the Prisma client instance.
- service – it contains all the graphql resolvers for our application.
So far, we have seen the structure and its functionalities. let’s create the graphql and resolvers for authentication.
we can create the sdl and services using the command,
yarn rw g sdl user yarn rw g service user
the above commands, create sdl and it’s respective service folders for us with all the CRUD logic for the specified domain.
As of now, our resolvers will contain createUser function which will have the db insert command. but, we can’t directly insert the password into the DB. so, let’s hash it before insert.
To do that, we need to install becryptjs and we also need jsonwebtoken to generate jwt
yarn workspace api add bcryptjs jsonwebtoken
Now, add to functions inside the services/users/users.js
export const loginUser = async ({ request }) => { try { const user = await db.user.findOne({ where: { email: request.email }, }) if (!user) { throw new Error('Invalid User') } const passwordMatch = await bcrypt.compare(request.password, user.password) if (!passwordMatch) { throw new Error('Invalid Login') } const token = jwt.sign( { id: user.id, username: user.email, }, 'my-secret-from-env-file-in-prod', { expiresIn: '30d', // token will expire in 30days } ) return { user, token } } catch (e) { return e } } export const createUser = async ({ input }) => { const password = await bcrypt.hash(input.password, 10) const data = { ...input, password } return db.user.create({ data, }) }
Other functionality would remain the same. you can test the login and signup functionality in graphql playground.
Now, that we have backend api ready. let’s create the frontend part of it. we can create components using the command,
yarn rw g page login yarn rw g page signup
above commands, creates the page for us along with routes. we just need to edit the page components and we are good to go.
import { Form, Label, TextField, FieldError, Submit, useMutation, } from '@redwoodjs/web' import { navigate, routes } from '@redwoodjs/router' import { useState } from 'react' const LOGIN_USER = gql` mutation LoginUser($input: loginUserInput) { loginUser(request: $input) { token user { id name email } } } ` const LoginPage = () => { const [state, setState] = useState({ email: '', password: '', }) const [loginUser] = useMutation(LOGIN_USER, { onCompleted: ({ loginUser }) => { console.log('loginUser', loginUser) localStorage.setItem('authToken', loginUser.token) setState({ email: '', password: '' }) setTimeout(() => { navigate(routes.home()) }, 2000) }, onError: (e) => { console.log(e) }, ignoreResults: false, }) const onSubmit = () => { console.log('on submit', state) loginUser({ variables: { input: { email: state.email, password: state.password, }, }, }) } const onChange = (e) => { setState({ ...state, [e.target.name]: e.target.value }) } return ( <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="sm:mx-auto sm:w-full sm:max-w-md"> <h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900"> Sign in </h2> </div> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <Form onSubmit={onSubmit}> <div> <Label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700" > Email address </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="email" type="email" name="email" value={state.email} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true, pattern: { value: /[^@]+@[^\.]+\..+/, }, }} /> </div> <FieldError name="email" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <Label htmlFor="password" className="block text-sm font-medium leading-5 text-gray-700" > Password </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="password" type="password" name="password" value={state.password} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true }} /> </div> <FieldError name="password" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <span className="block w-full rounded-md shadow-sm"> <Submit className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"> Sign in </Submit> </span> </div> </Form> </div> </div> <div className="mx-auto mt-5"> New User?{' '} <a className="cursor-pointer text-purple-600" onClick={() => { navigate(routes.signup()) }} > Create An Account </a> </div> </div> ) } export default LoginPage
Here, we have a form on submitting we get the data from the state and call the useMutation from redwood which is a wrapper on apollo graphql react hooks.
an important thing to note here is, we store the authToken in localStorage to implement the protected routes. Once login is successful, we redirect the user to home route. redwood provides the routes wrapper too.
const [loginUser] = useMutation(LOGIN_USER, { onCompleted: ({ loginUser }) => { console.log('loginUser', loginUser) localStorage.setItem('authToken', loginUser.token) setState({ email: '', password: '' }) setTimeout(() => { navigate(routes.home()) }, 2000) }, onError: (e) => { console.log(e) }, ignoreResults: false, })
In a same way, create the Signup page component.
import { Form, Label, TextField, FieldError, Submit, useMutation, } from '@redwoodjs/web' import { navigate, routes } from '@redwoodjs/router' import { useState } from 'react' const SINGUP_USER = gql` mutation CreateUserMutation($input: CreateUserInput!) { createUser(input: $input) { id name email } } ` const SignupPage = () => { const [state, setState] = useState({ name: '', email: '', password: '', }) const [signup] = useMutation(SINGUP_USER, { onCompleted: (createUser) => { localStorage.setItem('authToken', createUser.token) setState({ name: '', email: '', password: '' }) setTimeout(() => { navigate(routes.home()) }, 2000) }, }) const onSubmit = () => { console.log('on submit', state) signup({ variables: { input: { name: state.name, email: state.email, password: state.password, }, }, }) } const onChange = (e) => { setState({ ...state, [e.target.name]: e.target.value }) } return ( <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="sm:mx-auto sm:w-full sm:max-w-md"> <h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900"> Sign Up </h2> </div> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <Form onSubmit={onSubmit}> <div> <Label htmlFor="name" className="block text-sm font-medium leading-5 text-gray-700" > Name </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="name" type="text" name="name" value={state.name} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true, }} /> </div> <FieldError name="email" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <Label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700" > Email address </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="email" type="email" name="email" value={state.email} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true, pattern: { value: /[^@]+@[^\.]+\..+/, }, }} /> </div> <FieldError name="email" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <Label htmlFor="password" className="block text-sm font-medium leading-5 text-gray-700" > Password </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="password" type="password" name="password" value={state.password} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true }} /> </div> <FieldError name="password" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <span className="block w-full rounded-md shadow-sm"> <Submit className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"> Sign Up </Submit> </span> </div> </Form> </div> </div> <div className="mx-auto mt-5"> Already Have an Account?{' '} <a className="cursor-pointer text-purple-600" onClick={() => { navigate(routes.login()) }} > Login </a> </div> </div> ) } export default SignupPage
Now, that we have a complete authentication system for our application. let see how to create a recipe in application. we might need to implement a form.
Create a Recipe
so far, we have seen how to build an authentication system. let’s add a functionality to create recipe.
Backend
Firstly, let’s add the api for it and then frontend components.
yarn rw g sdl recipe yarn rw g service recipe
change graphql/recipes.sdl.js to add the create recipe and other functionalities in the recipe.
import gql from 'graphql-tag' export const schema = gql` type Recipe { id: Int! name: String! description: String! imageUrl: String! likes: Int! userId: Int User: User } type Query { recipes: [Recipe!]! } type Mutation { createRecipe(request: CreateRecipeInput): Recipe addLike(request: addLikeInput): Recipe } input addLikeInput { recipeId: Int likes: Int } input CreateRecipeInput { name: String! description: String! imageUrl: String! likes: Int userId: Int } input UpdateRecipeInput { name: String description: String imageUrl: String likes: Int userId: Int } `
Also, change service/recipes/recipes.js to add the functionalities in resolvers.
import { db } from 'src/lib/db' export const recipes = () => { return db.recipe.findMany() } export const Recipe = { user: (_obj, { root }) => db.recipe.findOne({ where: { id: root.id } }).user(), } export const createRecipe = ({ request }) => { console.log('request', request) return db.recipe.create({ data: request, }) } export const addLike = ({ request }) => { return db.recipe.update({ data: { likes: request.likes, }, where: { id: request.recipeId }, }) }
Frontend
create a page for recipe using the command,
yarn rw g page createrecipe
Now, we need to add functionalities for recipe page component.
import { Form, Label, TextField, TextAreaField, FieldError, Submit, useMutation, } from '@redwoodjs/web' import { navigate, routes } from '@redwoodjs/router' import { useState } from 'react' import NavbarLayout from '../../layouts/NavbarLayout' const CREATE_RECIPE_MUTATION = gql` mutation CreateRecipeMutation($input: CreateRecipeInput) { createRecipe(request: $input) { id likes name description likes imageUrl } } ` const CreateRecipePage = () => { const [state, setState] = useState({ name: '', description: '', imageUrl: '', uploadingState: 'NONE', }) const [createRecipe] = useMutation(CREATE_RECIPE_MUTATION, { onCompleted: () => { navigate(routes.home()) setState({ name: '', description: '', imageUrl: '', uploadingState: 'NONE', }) }, }) const onSubmit = () => { console.log('on submit', state) createRecipe({ variables: { input: { name: state.name, description: state.description, imageUrl: state.imageUrl, likes: 0, }, }, }) } const uploadFile = async (e) => { console.log('Uploading....') setState({ ...state, uploadingState: 'UPLOADING' }) const files = e.target.files const data = new FormData() data.append('file', files[0]) data.append('upload_preset', 'qy3oxqkx') const res = await fetch( 'https://api.cloudinary.com/v1_1/ganeshimaginary/image/upload', { method: 'POST', body: data, } ) const file = await res.json() setState({ ...state, imageUrl: file.secure_url, uploadingState: 'UPLOADED', }) } const onChange = (e) => { setState({ ...state, [e.target.name]: e.target.value }) } return ( <NavbarLayout> <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="sm:mx-auto sm:w-full sm:max-w-md"> <h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900"> Add Recipe </h2> </div> <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <Form onSubmit={onSubmit}> <div> <Label htmlFor="recipe-name" className="block text-sm font-medium leading-5 text-gray-700" > Recipe Name </Label> <div className="mt-1 rounded-md shadow-sm"> <TextField id="name" type="text" name="name" value={state.name} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" validation={{ required: true, }} /> </div> <FieldError name="email" className="text-red-500 text-xs" /> </div> <div className="mt-6"> <Label htmlFor="description" className="block text-sm font-medium leading-5 text-gray-700" > Description </Label> <div className="mt-1 rounded-md shadow-sm"> <TextAreaField id="description" name="description" value={state.description} onChange={onChange} className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5" /> </div> <FieldError name="description" className="text-red-500 text-xs" /> </div> <input type="file" onChange={uploadFile} /> <div className="mt-6"> <span className="block w-full rounded-md shadow-sm"> <Submit className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"> Submit </Submit> </span> </div> </Form> </div> </div> </div> </NavbarLayout> ) } export default CreateRecipePage
Now, that we have add recipe functionality in our application.
Data Fetching
Here we come to the final stage of the tutorial which is fetching the recipes from server and show it to the dashboard.
Redwood uses a concept called cell which helps the process of data fetching. it provides the state of loading , error and data.
Main purpose of having cell is separating the functionalities of data fetching outside from the component. so that, we can reuse the cell in other components as well.
let’s create a recipe cell which fetches the data from the server and show it the home page.
create a cell using the command,
yarn rw g cell recipe
After that, we need to add the rendering component based on the recipe data.
export const QUERY = gql` query { recipes { id imageUrl name description likes } } ` export const Loading = () => <div>Loading...</div> export const Empty = () => <div>Empty</div> export const Failure = ({ error }) => <div>Error: {error.message}</div> export const Success = ({ recipes }) => { return recipes.map((recipe, index) => ( <div key={index} className="w-1/4 bg-gray-500 md-flex max-w-sm rounded overflow-hidden shadow-lg m-2" > <img className="w-full" style={{ maxHeight: '200px' }} src={recipe.imageUrl} alt="Sunset in the mountains" /> <div className="px-6 py-4"> <div className="font-bold text-xl mb-2">{recipe.name}</div> <p className="text-gray-700 text-base">{recipe.description}</p> </div> <div className="px-6 py-4"> <span> <i className="fa fa-thumbs-o-up" aria-hidden="true"></i>{' '} {recipe.likes} </span> </div> </div> )) }
Conclusion
We learned how to build a full-stack application using redwoodjs. I hope this article covered some of the important modules for building a full-stack application. Note that, redwood is still under development and not ready for production yet. So, learn the concept of redwood and keep an eye on it. it can become a new way of building a full-stack application.
Complete source code can be found here