01Jul
Building a Full Stack Application using RedwoodJs
Building a Full Stack Application using RedwoodJs

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

Client and Server-Side in a Mono-Repo Folder Structure
Client and Server-Side in a Mono-Repo Folder Structure

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

Domain Model
Domain Model

Here, we have user and Recipe domains. an application user story will be like,

User Story

  1. Users can log in and Signup into the application and view all the recipes created by different users.
  2. 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.

Structure of Backend Code
Structure of 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.

Login and Signup Functionality in Graphql Playground
Login and Signup Functionality in Graphql Playground
Login and Signup Functionality in Graphql Playground
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.

Recipe Form
Recipe Form

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

Leave a Reply