How to persist data in nextjs app?
Tags: webdev, react, javascript, nextjs
Published: 2021-09-04 04:24:45

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5qcwap670p71d8spli6o.png)

#### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jr5o8g92279rjin6ept2.png)

Thank to [this amazing article](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/).

### Implement

#### Setup

#### Nextjs and Redis Image

* Init a nextjs app with `create-next-app`:

```
npx create-next-app --ts
```
* Create a `docker-compose.yml` with a `redis` services:
```yml
version: '3.9'
services:
  redis:
    container_name: redis
    image: redis:alpine
    ports:
      - '6300:6379'
```

#### 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
```

and create severals `utils` to interact with `redis`:

*Path: `lib/redis.ts`*.

```tsx
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}

```

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

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

```tsx
// 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))
}

```
##### 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`*.

```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}


```

Take `RedisKeyProvider` to `_app.tsx`:

```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

```

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

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

```tsx
// 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})
}
```

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`*.


```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

```

```tsx
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
```

Now, I can see the data is persisted from `step1` to `step2`.
![App Demo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/awn2o02gt2pcegnfypq1.gif)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9dxq6rv1a23wbivx2knv.gif)

That's way I persist the data in a nextjs app with `redis`. All of code is pushed in [here](https://codesandbox.io/s/demo-redis-2yzhj).