How to persist data in nextjs app?

thangphan37

Phan Công Thắng

Posted on September 4, 2021

How to persist data in nextjs app?

In my app, I do have many screen flows that user has to input the previous step before they can move on next step. And I'd like to persist the data in the previous step and get them in the next step.

We have a lot of methods to persist data as using: localStorage, cookies, firebase, dynamoDB, etc..

But I chose redis in order to achieve this goal. Because of the data that was saved in the memory of the computer, we can get it faster than these methods above.

Design flow

That I want

I need to do three things:

  1. When I move on next step, then go back the previous step, data is persisted.
  2. Next step need to get data of previous step.
  3. If the next step doesn't have the data of the previous step, it's redirected to the previous step. The current step can have data or not, and it doesn't need to be redirected.

Data Steps

Redis

I need to create a unique key in order to save the data in redis. I don't want to save key in localStorage(XSS attack), cookies(CSRF attack).

Then I decided:

  1. I will use a refreshKey, save it in cookies, and use it to get key in redis. The key will be saved in state of React, and it will be safe.
  2. I will use hashMapKey(in key) that includes the data of each step(for performance reason).

Redis Logic

Thank to this amazing article.

Implement

Setup

Nextjs and Redis Image

  • Init a nextjs app with create-next-app:
npx create-next-app --ts
Enter fullscreen mode Exit fullscreen mode
  • Create a docker-compose.yml with a redis services:
version: '3.9'
services:
  redis:
    container_name: redis
    image: redis:alpine
    ports:
      - '6300:6379'
Enter fullscreen mode Exit fullscreen mode

Coding

Redis

We need to install node-redis in order to connect with redis server from docker image above.

npm install redis@^3.1.2
Enter fullscreen mode Exit fullscreen mode

and create severals utils to interact with redis:

Path: lib/redis.ts.

import redis from 'redis'
const client = redis.createClient({
  url: process.env.REDIS_URL as string,
})

client.on('error', function (error) {
  console.error(error)
})

async function setAsync(key: string, value: string) {
  return new Promise((resolve) => {
    client.set(key, value, (error, reply) => {
      if (error) {
        console.log(`REDIS get error with SET: ${key}`, error)
      }

      resolve(reply)
      client.expire(key, 60 * 60 * 24)
    })
  })
}

async function getAsync(key: string) {
  return new Promise((resolve) => {
    client.get(key, (error, reply) => {
      if (error) {
        console.log(`REDIS get error with SET: ${key}`, error)
      }

      resolve(reply)
    })
  })
}

async function hmSetAsync(key: string, field: string, data: string) {
  return new Promise((resolve) => {
    client.hmset(key, field, data, (error, reply) => {
      if (error) {
        console.log(`REDIS get error with HMSET: ${key}`, error)
      }

      resolve(reply)
      client.expire(key, 60 * 60 * 24)
    })
  })
}

async function hmGetAsync(key: string, field: string) {
  return new Promise((resolve) => {
    client.hmget(key, field, (error, reply) => {
      if (error) {
        console.log(`REDIS get error with HMGET: ${key}`, error)
      }

      resolve(reply)
    })
  })
}

type ScreenConfig = {
  hmKey: string
  path: string
  isCurrent?: boolean
}

async function getDataFromRedis(key: string, configs: Array<ScreenConfig>) {
  const data = (
    await Promise.all(configs.map(({hmKey}) => hmGetAsync(key, hmKey)))
  )
    .flat()
    .map((d) => (typeof d === 'string' ? JSON.parse(d) : d))

  // we don't need to check data in the current page.
  const haveAllData = data.every((d, idx) => configs[idx].isCurrent ?? d)

  if (haveAllData) {
    return {
      shouldRedirect: false,
      data,
    }
  }

  // redirect to the previous step that doesn't have data.
  const index = data.findIndex((d) => !d)
  const redirectPath = configs[index].path
  return {
    shouldRedirect: true,
    redirectPath,
  }
}

export {setAsync, hmSetAsync, getAsync, hmGetAsync, getDataFromRedis}

Enter fullscreen mode Exit fullscreen mode

Let's create api routes in nextjs in order to create key in redis.

Path: pages/api/your-key.ts.

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from 'next'
import {parse, serialize, CookieSerializeOptions} from 'cookie'
import {getAsync, setAsync} from '../../lib/redis'
import {v4 as uuidv4} from 'uuid'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (req.headers.cookie !== undefined) {
    const cookie = parse(req.headers.cookie)

    if (cookie.refreshKey) {
      const key = await getAsync(cookie.refreshKey)
      console.log('key', key)
      return res.status(200).json({key})
    }
  }

  const refreshKey = uuidv4()
  const key = uuidv4()

  const start = Date.now()
  await setAsync(refreshKey, key)

  // sync time expire between redis and cookie
  const timeForRedis = Math.floor(Date.now() - start) / 1000
  setCookie(res, 'refreshKey', refreshKey, {
    maxAge: 60 * 60 * 24 - timeForRedis,
  })
  res.status(200).json({key})
}

export const setCookie = (
  res: NextApiResponse,
  name: string,
  value: unknown,
  options: CookieSerializeOptions = {},
) => {
  const stringValue =
    typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)

  if (options.maxAge) {
    options.expires = new Date(Date.now() + options.maxAge)
    // options.maxAge /= 1000
    options.path = '/'
  }

  res.setHeader('Set-Cookie', serialize(name, String(stringValue), options))
}

Enter fullscreen mode Exit fullscreen mode
React

I'd like to cache the key when we make a transition between pages in nextjs. Fortunately, we already have swr. I will combine swr in a global context. Then when we move between pages we can take the key from caching instead of getting from API.

Path: context/redis-key-context.tsx.

import * as React from 'react'
import useSWR from 'swr'

const RedisKeyContext = React.createContext(null)
const fetcher = (args: string) => fetch(args).then((res) => res.json())

function RedisKeyProvider({children}: {children: React.ReactNode}) {
  const {data, error} = useSWR('api/your-key', fetcher)
  const value = React.useMemo(() => data, [data])
  if (error) {
    return <div>Hmm, Something wrong with your key.</div>
  }

  return (
    <RedisKeyContext.Provider value={value}>
      {children}
    </RedisKeyContext.Provider>
  )
}

function useRedisKey() {
  const context = React.useContext(RedisKeyContext)

  if (context === null) {
    throw new Error(`useRedisKey must be used within a RedisKeyProvider.`)
  }

  return context
}

export {RedisKeyProvider, useRedisKey}


Enter fullscreen mode Exit fullscreen mode

Take RedisKeyProvider to _app.tsx:

import '../styles/globals.css'
import type {AppProps} from 'next/app'
import {RedisKeyProvider} from '../context/redis-key-context'

function MyApp({Component, pageProps}: AppProps) {
  return (
    <RedisKeyProvider>
      <Component {...pageProps} />
    </RedisKeyProvider>
  )
}
export default MyApp

Enter fullscreen mode Exit fullscreen mode

And We have to create the api in order to save data to redis.

Path: pages/api/your-data.ts.

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from 'next'
import {parse} from 'cookie'
import {hmSetAsync} from '../../lib/redis'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (!req.headers.cookie) {
    return responseError(res, 'Hmm, Something wrong with your refreshKey.')
  } else {
    const cookie = parse(req.headers.cookie)

    if (!cookie.refreshKey) {
      return responseError(res, 'Hmm, Something wrong with your refreshKey.')
    }
  }

  const {hmKey, key, data} = JSON.parse(req.body)

  if (!hmKey) {
    return responseError(res, 'A hashmap key is required.')
  }

  if (!key) {
    return responseError(res, 'A key is required.')
  }

  if (!data) {
    return responseError(res, 'Data is required.')
  }

  await hmSetAsync(key, hmKey, JSON.stringify(data))
  res.status(200).json({data})
}

function responseError(res: NextApiResponse, message: string) {
  return res.status(404).json({message})
}
Enter fullscreen mode Exit fullscreen mode

I'm going to create two pages step1.tsx and step2.tsx. And I will adapt the API that I just created to these pages..

Path: pages/step1.tsx, pages/step2.tsx.

import {parse} from 'cookie'
import {getAsync, getDataFromRedis} from '../lib/redis'
import {useRedisKey} from '../context/redis-key-context'
import type {NextApiRequest} from 'next'
import Router from 'next/router'
import * as React from 'react'

export type Step = {
  title: string
  content: string
}

function StepOne({step1}: {step1: Step}) {
  const redisKey = useRedisKey()
  async function handleStepNext() {
    const data = {
      key: redisKey.key,
      hmKey: 'steps:1',
      data: {
        title: 'Step1',
        content: 'Content of step1',
      },
    }

    await fetch('api/your-data', {
      method: 'POST',
      body: JSON.stringify(data),
    })

    Router.push('/step2')
  }
  return (
    <div>
      <section>
        <h1>Data from StepOne</h1>
        <p>{step1?.title}</p>
        <p>{step1?.content}</p>
      </section>
      <button onClick={handleStepNext}>Next step</button>
    </div>
  )
}

export async function getServerSideProps({req}: {req: NextApiRequest}) {
  if (req.headers.cookie !== undefined) {
    const cookie = parse(req.headers.cookie)

    if (cookie.refreshKey) {
      const key = await getAsync(cookie.refreshKey)

      if (typeof key === 'string') {
        const {shouldRedirect, data} = await getDataFromRedis(key, [
          {
            hmKey: 'steps:1',
            path: '/step1',
            isCurrent: true,
          },
        ])

        if (!shouldRedirect) {
          const step1 = data ? data[0] : null
          return {
            props: {
              step1,
            },
          }
        }
      }
    }
  }

  return {
    props: {
      step1: {},
    },
  }
}

export default StepOne

Enter fullscreen mode Exit fullscreen mode
import {parse} from 'cookie'
import {getAsync, getDataFromRedis} from '../lib/redis'
import {useRedisKey} from '../context/redis-key-context'
import type {NextApiRequest} from 'next'
import type {Step} from './step1'
import * as React from 'react'

function StepTwo({step1, step2}: {step1: Step; step2: Step}) {
  const redisKey = useRedisKey()
  async function makeStep2Data() {
    const data = {
      key: redisKey.key,
      hmKey: 'steps:2',
      data: {
        title: 'Step2',
        content: 'Content of step2',
      },
    }

    await fetch('api/your-data', {
      method: 'POST',
      body: JSON.stringify(data),
    })
  }

  return (
    <div>
      <section>
        <h1>Data from StepOne</h1>
        <p>{step1?.title}</p>
        <p>{step1?.content}</p>
      </section>
      <section>
        <h1>Data of StepTwo</h1>
        <p>{step2?.title}</p>
        <p>{step2?.content}</p>
      </section>
      <button onClick={makeStep2Data}>Make</button>
    </div>
  )
}

export async function getServerSideProps({req}: {req: NextApiRequest}) {
  if (req.headers.cookie !== undefined) {
    const cookie = parse(req.headers.cookie)

    if (cookie.refreshKey) {
      const key = await getAsync(cookie.refreshKey)

      if (typeof key === 'string') {
        const {shouldRedirect, data, redirectPath} = await getDataFromRedis(
          key,
          [
            {
              hmKey: 'steps:1',
              path: '/step1',
            },
            {
              hmKey: 'steps:2',
              path: '/step2',
              isCurrent: true,
            },
          ],
        )

        // redirect to the previous step.
        if (shouldRedirect) {
          return {
            redirect: {
              destination: redirectPath,
              permanent: false,
            },
          }
        }

        const step1 = data ? data[0] : null
        const step2 = data ? data[1] : null
        return {
          props: {
            step1,
            step2,
          },
        }
      }
    }
  }

  return {
    redirect: {
      destination: '/step1',
      permanent: false,
    },
  }
}

export default StepTwo
Enter fullscreen mode Exit fullscreen mode

Now, I can see the data is persisted from step1 to step2.
App Demo

If I delete data of step1 from redis, It will redirect to /step1 route. It makes sure user has to fill out data in step1 before move on step2.

Redirect to Step1

That's way I persist the data in a nextjs app with redis. All of code is pushed in here.

💖 💪 🙅 🚩
thangphan37
Phan Công Thắng

Posted on September 4, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related