How to persist data in nextjs app?
Phan Công Thắng
Posted on September 4, 2021
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:
- When I move on next step, then go back the previous step, data is persisted.
- Next step need to get data of previous step.
- 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.
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:
- I will use a
refreshKey
, save it incookies
, and use it to getkey
inredis
. Thekey
will be saved instate
ofReact
, and it will be safe. - I will use
hashMapKey
(inkey
) that includes the data of each step(for performance reason).
Thank to this amazing article.
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 aredis
services:
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
.
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
.
// 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
.
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
:
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
.
// 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
.
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
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
.
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
.
That's way I persist the data in a nextjs app with redis
. All of code is pushed in here.
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
February 20, 2024