DEV Community

Cover image for How to develop a fullstack Q&A app with Fauna and Next.js
Amolo
Amolo

Posted on

How to develop a fullstack Q&A app with Fauna and Next.js

INTRODUCTION

Next.js is a powerful open source React framework. It enables features such as server-side rendering, API routes that you can use to build REST API endpoints within your Next.js app and consume it within the same app or any other app. This way, the frontend and backend can also be unified into a single codebase.
Fauna’s database is well optimized for the JamStack through its API first approach while offering powerful and useful query features through its own query language (FQL).
In this tutorial we will use Next.js and Fauna to build out a full stack Q&A application.
This application will:

  1. Allow users to answer questions
  2. Get their scores in real time

Project Setup.

The recommended way to initialize a Next.js app is by using create-next-app which sets up everything automatically for you.

You will be required to either have yarn or npm installed.

yarn create next-app qna
Enter fullscreen mode Exit fullscreen mode

If you prefer to use npx you can run the below equivalent command.

npx create-next-app qna
Enter fullscreen mode Exit fullscreen mode

Once this is complete,cd into the folder.

cd qna
Enter fullscreen mode Exit fullscreen mode

Basic Application Structure

├── package.json
├── pages
│   ├── api
│   ├── _app.js
│   └── index.js
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── README.md
├── styles
│   ├── globals.css
│   └── Home.module.css
└── yarn.lock
Enter fullscreen mode Exit fullscreen mode

Install the faunadb npm package to allow our application to interact with our Fauna Instance.

yarn add faunadb
Enter fullscreen mode Exit fullscreen mode

Setup your Fauna database.

To store all our application’s data, we will first need to create a database.
Fortunately, this is just a single command or line of code, as shown below.
Don’t forget to create a Fauna account before continuing.

Fauna Shell

Fauna's API has various interfaces/clients, such as drivers in Javascript, Golang, Python, Java and more, a cloud console, local and cloud shells, and even a VS Code extension! For this article, we’ll start with the local Fauna Shell, which is almost 100% interchangeable with the other interfaces.

You will first be required to install the Fauna shell on your machine with the following command.

npm install -g fauna-shell
Enter fullscreen mode Exit fullscreen mode

After installing the Fauna Shell with npm or yarn, log in with your Fauna credentials using the fauna cloud-login command:

$ fauna cloud-login
For email login, enter your email below, and then your password.
For login with 3rd-party identity providers like Github or Netlify, please acquire a key from 
Dashboard > Security and enter it below instead.

Email: email@example.com
Password: **********
Enter fullscreen mode Exit fullscreen mode

Now we are able to create our database.

fauna create-database qna
Enter fullscreen mode Exit fullscreen mode

Create Collections and Indexes.

To start a shell with your new database, run:

fauna shell qna
Enter fullscreen mode Exit fullscreen mode

We can now operate our database from this shell.

$ fauna shell qna
Starting shell for database qna
Connected to https://db.fauna.com
Type Ctrl+D or .exit to exit the shell
qna>
Enter fullscreen mode Exit fullscreen mode

In the case of our application, we will have two collections.

  1. Questions - This will hold information about the questions.
  2. Answers - The responses provided by the users. We will also use this collection to grade the responses.

Creating collections

To create our questions collection, run the following command in the shell to create the collection with the default configuration.

qna> CreateCollection({ name: "questions" })
Enter fullscreen mode Exit fullscreen mode

Next, let’s do the same for the answers collections.

qna> CreateCollection({ name: "answers" })
Enter fullscreen mode Exit fullscreen mode

Expected Output

Alt Text

DATA SCHEMA

Questions Collection

Each question will have the following fields
question_text - A questions eg. “Does Next.js support SSR or SSG?”
correct_answer - The correct answer to the question asked in (1) eg. “Both”
options - Distractors to the correct answer eg. [“SSR”, “SSG”]

Answers Collection

Each question response (answer) will have the following fields

  • question_id - a reference to the question in the questions collection.
  • user_id - A unique identifier for the respondent.(This value will be automatically generated and stored in the browser.)
  • response - The user’s response from a list of possible options.
  • isCorrect - A Boolean value to indicate it the answer provided is their correct (true) or incorrect (false)

INDEXING YOUR DATA.

Fauna highly recommends indexing your data for the purposes of searching, sorting and combining results from multiple collections.

In this Q&A app, a user will be allowed to attempt and respond to a question only once. We can enforce this constraint in our answers collection by creating an index as follows.

qna> CreateIndex({
...   name: "unique_question_user",
...   unique: true,
...   serialized: true,
...   source: Collection("answers"),
...   terms: [
...     {
.....       field: ["data", "user_id"]
.....     },
...     {
.....       field: ["data", "question_id"]
.....     }
...   ]
... })
Enter fullscreen mode Exit fullscreen mode

If the index was created successfully, you should get a similar response.

#SAMPLE RESPONSE…...
{
  ref: Index("unique_question_user"),
  ts: 1610301037970000,
  active: true,
  serialized: true,
  name: 'unique_question_user',
  unique: true,
  source: Collection("answers"),
  terms: [
    { field: [ 'data', 'user_id' ] },
    { field: [ 'data', 'question_id' ] }
  ],
  partitions: 1
}
Enter fullscreen mode Exit fullscreen mode

The second index we will create is to enable us get all answers by a particular user

READING AND SAVING DATA

Next.js supports multiple ways or obtaining data from the remote source eg. API or a database.
Use of getServersideProps. This props can thereafter be passed to the exported component
Using API Routes - API routes provide a straightforward solution to build your API with Next.js.Any file inside the folder pages/api is mapped to /api/* and will be treated as an API endpoint instead of a page. They are server-side only bundles and won't increase your client-side bundle size.

Now that we know about API Routes, let's create an HTTP endpoint to allow us to create a question with a simple POST request.

In the root of our application directory, in the pages/api folder, lets create a file named createQuestion.js and add the following code.

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import faunadb, {query as q} from 'faunadb';
const client = new faunadb.Client({secret: process.env.FAUNA_SECRET })

export default async (req, res) => {
   if(req.method == 'POST'){
       let {question_text, correct_answer, options } = req.body
       let results = await client.query(
           q.Create(q.Collection('questions'),
           { data : {question_text, correct_answer, options}})
       )
       console.log(results)
       res.json({ id: results.ref.id, data: results.data })
   }
 }
Enter fullscreen mode Exit fullscreen mode

CREATE A FAUNA KEY

In order for our application to send and receive data to Fauna we will need to create a key and provide its secret when performing queries.
For this application, a key with a Server Role is sufficient to create, read and delete data.
Head over to your database’s Fauna Shell and create a key using the following command.

CreateKey({
      name: "qna-app",
      role: "server"
   })
# Example result.
# NOTE: Make sure you copy and store the secret!
# {
#   ref: Ref(Keys(), "280185139463529993"),
#     ts: 1603464278974000,
#     role: 'server',
#     secret: 'fnAD62i0bTBCDRjYjkcttAsY3wVyfsvynwUSqxYG',
#     hashed_secret: ...
# }
Enter fullscreen mode Exit fullscreen mode

This next step is critical. Copy the secret generated and set it on your project environment by running the command below. Note that secrets are only shown once after creating keys; you’ll have to create a new key if you lose the original secret.
Create a .env.local file in the application root, and here we will place this key

# .env.local 
FAUNA_SECRET=fn……………………………….
Enter fullscreen mode Exit fullscreen mode

Once you are done we can start our development server by running

$ yarn dev
Enter fullscreen mode Exit fullscreen mode

SEED INITIAL QUESTIONS

Now that we have an API running at http://127.0.0.1:3000/api/createQuestion we can seed some initial questions to our database by using simple curl commands.

$ curl --location --request POST 'http://127.0.0.1:3000/api/createQuestion' \
--header 'Content-Type: application/json' \
--data-raw '{
    "question_text":"How many items in a dozen?",
    "correct_answer": "12",
    "options": ["6", "10"]
}'
Enter fullscreen mode Exit fullscreen mode
$ curl --location --request POST 'http://127.0.0.1:3000/api/createQuestion' \
--header 'Content-Type: application/json' \
--data-raw '{
    "question_text":"How many bits in a byte?",
    "correct_answer": "8",
    "options": ["6", "10", "12", "16" ]
}'
Enter fullscreen mode Exit fullscreen mode

Let’s also create an API endpoint that can be used to evaluate question response.

In the pages/api folder, let's create a file named evaluateResponse.js and add the following code.
This API endpoint will be available at http://127.0.0.1:3000/api/evaluateResponse and shall be invoked whenever a users response needs to be evaluated.

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import faunadb, {query as q} from 'faunadb';
const client = new faunadb.Client({secret: process.env.FAUNA_SECRET })

export default async (req, res) => {
   if(req.method == 'POST'){
       let {question_id, user_id, answer } = req.body
       if (!(question_id && answer && user_id)){
           res.json({ error: "Fields question_id & answer & user_id should be provided." })
       }
       else {
           let results = await client.query(
               q.Get( q.Ref(q.Collection('questions'), question_id)))
           let question = results.data
           let isCorrect = false
           if ( question.correct_answer === answer ){ isCorrect = true }
           try{
               let query = await client.query(
                   q.Create(q.Collection('answers'),
                       { data : { question_id, user_id, isCorrect: isCorrect, response: answer }})
               )
               query.data.correct = question.correct_answer
               res.json({ ref: query.ref.id, data: query.data }) 
           }catch(error){
               if(error.message === 'instance not unique'){
                   res.json({error: 'Question is already answered'})
               }
           }                   
   }
 }
}
Enter fullscreen mode Exit fullscreen mode

We can now start working on the UI.

To create a UI quickly, we will use the react-bootstrap library and use some ready made UI components.

$ yarn add react-bootstrap bootstrap
Enter fullscreen mode Exit fullscreen mode

Next, add the change the default style in the pages/_app.js file to bootstrap as shown.

// pages/_app.js

import 'bootstrap/dist/css/bootstrap.min.css'

function MyApp({ Component, pageProps }) {
 return <Component {...pageProps} />
}

export default MyApp
Enter fullscreen mode Exit fullscreen mode

USERS

As noted above, we will be required to uniquely identify users sowe will generate random user ids that will be saved in the cookies.
We will use nookies to easily create and read cookie data.

yarn add nookies
Enter fullscreen mode Exit fullscreen mode

getServerSideProps

Our cookies will be generated and set in the serverSideProps of our index.js.
If the cookies are available, they will be used to save user responses and also identify already attempted questions to prevent them from being loaded to the user again.

// pages/index.js
let cookies = parseCookies(context)
 if(!cookies.user_id){
   setCookie(context, 'user_id', `${rw()}${Math.floor((Math.random() * 999) + 900)}`, {
     maxAge: 7 * 24 * 60 * 60, path: '/', })
 }
Enter fullscreen mode Exit fullscreen mode

In the same function, we will also retrieve a not attempted question from our questions collection in Fauna using the FQL Difference function
This will enable us to compare the entire collections of questions that are missing from a list of questions that have already been attempted by the user.
This will enable us to select the next question for the user.

We will use the following FQL query.

// pages/index.js
let query = await db.query(
   q.Difference(
     //Get All questions
     q.Select('data', q.Map(
       q.Paginate(q.Documents(q.Collection('questions'))), q.Lambda('ref', q.Var('ref')))),
     //Get  Attempted Questions
     q.Select('data', q.Map(
       q.Paginate( q.Match(q.Index('questions_attempted_by_user'), cookies.user_id)),
       q.Lambda('question_id', q.Ref(q.Collection('questions'), q.Var('question_id')))
     ))
   )
 )
Enter fullscreen mode Exit fullscreen mode

Finally, update the pages/index.js file to be as below.

import Head from 'next/head'
import React, { useState, useEffect } from 'react'

import { parseCookies, setCookie, destroyCookie } from 'nookies'

import faunadb, {query as q} from 'faunadb';
const db = new faunadb.Client({secret: process.env.FAUNA_SECRET })
import rw from 'random-words'

//Bootstrap Components
import Card from 'react-bootstrap/Card'
//Import Custom Components
import Question from '../components/Question'

export default function Home( { question, auth } ) {

 let [questionId, setQuestionId] = useState(null)
 let [userId, setUserId] = useState(null)
 let cookies = parseCookies()

 return (
   <div className="container">
     <h5 style={{paddingTop:"3em"}}>🤔 Questions need answers</h5>
     <hr/>
     <Card>
       <Card.Header>
         <h5 style={{float:'right'}}>Hello {cookies.user_id}</h5>
       </Card.Header>

           <Question question={ question } />

       <p></p>
     </Card>
     <Card.Footer>
     </Card.Footer>
   </div>
 )
}

export async function getServerSideProps(context) {
 //Check for cookies and setCookie if none
 let cookies = parseCookies(context)
 if(!cookies.user_id){
   setCookie(context, 'user_id', `${rw()}${Math.floor((Math.random() * 999) + 900)}`, {
     maxAge: 7 * 24 * 60 * 60, path: '/', })
 }

 // Fetch questions
 let query = await db.query(
   q.Difference(
     //All questions
     q.Select('data', q.Map(
       q.Paginate(q.Documents(q.Collection('questions'))), q.Lambda('ref', q.Var('ref')))),
     // Attempted Questions
     q.Select('data', q.Map(
       q.Paginate( q.Match(q.Index('questions_attempted_by_user'), cookies.user_id)),
       q.Lambda('question_id', q.Ref(q.Collection('questions'), q.Var('question_id')))
     ))
   )
 )

 let question = null
 if(query.length > 0){
   let result = await db.query(q.Get(query[0]))
   question = result.data
   question.id = result.ref.id
 }

 return {
   props: {
     question,
   }, // will be passed to the page component as props
 }
}
Enter fullscreen mode Exit fullscreen mode

Then create a components folder and in the ./components/Question.jsx add the following code for our question’s component.

import React, {useState} from 'react'
import Card from 'react-bootstrap/Card'
import Form from 'react-bootstrap/Form'
import Button from 'react-bootstrap/Button'
import { parseCookies } from 'nookies'
import {useRouter} from 'next/router'
import Alert from 'react-bootstrap/Alert'

export default function Question({ question }){

   let [answer, setAnswer ] = useState(null)
   let [evaluated, setEvaluated] = useState(null)

   let router = useRouter()
   let cookies = parseCookies()
   let user_id = cookies.user_id

   let submitResponse = async () => {
       let request = await fetch('/api/evaluateResponse', {
           headers:{ 'Content-Type': 'application/json'},
           body: JSON.stringify({ question_id: question.id, user_id: user_id, answer: answer}),
           method: "POST",
       })
       let response = await request.json()
       setEvaluated(response.data)
       setTimeout(function(){
           setEvaluated(null)
           router.push('/')}, 2500)
   }

   return(
       <>
       {evaluated ? <Alert variant="info">You answer was {evaluated.isCorrect ?
           "correct": `wrong. Correct answer is ${evaluated.correct}`}</Alert> : <></>}
       {question ? <Card.Body>
           <h4>{question.question_text}</h4>
           <hr/>
           {(question.options.concat(question.correct_answer)).map((answer, idx)=>{
               return ( <h4 key={idx}>
                           <Form.Check type="radio"
                               onChange={e => {setAnswer(e.target.value)}}  value={answer} name="options" label={answer} />
                        </h4> )
           })}
           <div className="container">
               {   answer ?
                   <Button className="col-sm-12 col-lg-12 col-md-12" variant="warning" onClick={submitResponse}>Answer</Button> :
                   <Button className="col-sm-12 col-lg-12 col-md-12" variant="warning" disabled>Answer</Button>
               }
           </div>
       </Card.Body> : <h4>You have answered all available questions.</h4>
       }
       </>
   )
}
Enter fullscreen mode Exit fullscreen mode

When we run the dev server

yarn dev
Enter fullscreen mode Exit fullscreen mode

When you visit http://localhost:3000 you will be greeted with the questions page as shown below.

Alt Text

Deploy to Vercel

To deploy our app to Vercel, we first need to install the Vercel CLI by running the following command.

npm i -g vercel
Enter fullscreen mode Exit fullscreen mode

Ensure you have a Vercel account, or head over to vercel.com to create one.
Once registered, run the following command to login to the CLI with your account.

vercel login
Enter fullscreen mode Exit fullscreen mode

Follow the prompts to confirm your email.
Once you successfully login, run the following command to setup and deploy the app to Vercel.

vercel
Enter fullscreen mode Exit fullscreen mode
$ vercel
Vercel CLI 20.1.1
? Set up and deploy “~/x/qna”? [Y/n] y
? Which scope do you want to deploy to? Bryan
? Link to existing project? [y/N] n
? What’s your project’s name? qna
? In which directory is your code located? ./
Auto-detected Project Settings (Next.js):
- Build Command: `npm run build` or `next build`
- Output Directory: Next.js default
- Development Command: next dev --port $PORT
? Want to override the settings? [y/N] n

🔍  Inspect: https://vercel.com/amolo/qna/ikxz9cpa2 [5s]
✅  Preview: https://qna.amolo.vercel.app [copied to clipboard] [48s]
📝  To deploy to production, run `vercel --prod`
Enter fullscreen mode Exit fullscreen mode

Next we will need to add the FAUNA_SECRET environment variable to allow our app interact with Fauna.

vercel env add
Enter fullscreen mode Exit fullscreen mode

Follow the prompts as shown below

$ vercel env add
Vercel CLI 20.1.1
? What’s the name of the variable? FAUNA_SECRET
? What’s the value of FAUNA_SECRET? [hidden]
? Add FAUNA_SECRET to which Environments (select multiple)? Production, Preview,
 Development
✅  Added Environment Variable FAUNA_SECRET to Project qna [2s]
Enter fullscreen mode Exit fullscreen mode

Finally we can deploy our app with

vercel  --prod
Enter fullscreen mode Exit fullscreen mode
$ vercel --prod
Vercel CLI 20.1.1
🔍  Inspect: https://vercel.com/amolo/qna/co2hv7ces [2s]
✅  Production: https://qna-seven.vercel.app [copied to clipboard] [35s]
Enter fullscreen mode Exit fullscreen mode

Your app is now live.
You can visit the demo on https://qna-seven.vercel.app

Conclusion

For this tutorial, we are able to see how fast it can be to develop a full stack application with Fauna and Next.js.
Next.js provides a highly productive, powerful and fast framework that we can use to develop both backend and frontend components of our full stack app.
Secondly, we can see how Fauna is indeed a powerful database; with a powerful FQL, which supports complex querying and integration with the serverless and JAMStack ecosystem through its API first approach. This enables developers to simplify code and ship faster.

I hope you find Fauna to be exciting, like I do, and that you enjoyed this article. Feel free to follow me on Twitter @theAmolo if you enjoyed this!

All code written for this tutorial can be found in the following Github Repo

Discussion (5)

Collapse
jiftuq profile image
chris

Thanks

Collapse
amolo profile image
Amolo Author

Always welcome Chris.

Collapse
jiftuq profile image
chris

I get this when i try to run it
API resolved without sending a response for /api/createQuestion, this may result in stalled requests.

Thread Thread
amolo profile image
Amolo Author

I haven't received that error on my end. But you could try adding this at the end of the api/createQuestion file

export const config = {
    api:{
        externalResolver: true,
    },
}
Enter fullscreen mode Exit fullscreen mode
Collapse
kostasx profile image
Kostas Minaidis

The index is first declared as "unique_question_user" and later on in the code (index.js) it is referred to as "questions_attempted_by_user".