Skip to content

Commit e2f3059

Browse files
Add a self-hosted shared cache example (#58000)
### What? This pull request integrates the exemplary setup for a self-hosted Next.js application utilizing Redis as a shared cache storage. The solution supports caching at both the App and Pages routers in default and standalone modes, as well as partial pre-rendering, facilitated by the [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache/tree/canary/packages/cache-handler) package. The package enables customizing cache handlers and replacing the default cache provided by Next.js seamlessly. ### Why? The motivation behind this pull request is to provide an example demonstrating how Redis can be used as a shared cache in a self-hosted environment, thereby improving the scalability of hosting multiple instances of a Next.js application.
1 parent 0cdddd4 commit e2f3059

File tree

16 files changed

+657
-3
lines changed

16 files changed

+657
-3
lines changed

docs/02-app/01-building-your-application/08-deploying/index.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,14 @@ module.exports = {
153153
}
154154
```
155155

156-
Then, create `cache-handler.js` in the root of your project. This file can save the cached values anywhere, like Redis or AWS S3, for example:
156+
Then, create `cache-handler.js` in the root of your project, for example:
157157

158158
```jsx filename="cache-handler.js"
159159
const cache = new Map()
160160

161161
module.exports = class CacheHandler {
162162
constructor(options) {
163163
this.options = options
164-
this.cache = {}
165164
}
166165

167166
async get(key) {
@@ -190,7 +189,7 @@ module.exports = class CacheHandler {
190189
}
191190
```
192191

193-
Using a custom cache handler will allow you to ensure consistency across all pods hosting your Next.js application.
192+
Using a custom cache handler will allow you to ensure consistency across all pods hosting your Next.js application. For instance, you can save the cached values anywhere, like [Redis](https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis) or AWS S3.
194193

195194
> **Good to know:**
196195
>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
.yarn/install-state.gz
8+
9+
# testing
10+
/coverage
11+
12+
# next.js
13+
/.next/
14+
/out/
15+
16+
# production
17+
/build
18+
19+
# misc
20+
.DS_Store
21+
*.pem
22+
23+
# debug
24+
npm-debug.log*
25+
yarn-debug.log*
26+
yarn-error.log*
27+
28+
# local env files
29+
.env*.local
30+
31+
# vercel
32+
.vercel
33+
34+
# typescript
35+
*.tsbuildinfo
36+
next-env.d.ts
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Next.js Redis Cache Integration Example
2+
3+
This repository provides a production-ready example of how to enhance the caching capabilities of Next.js and use Redis to share the cache for multiple instances of your app. It's made possible by the [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache/tree/canary/packages/cache-handler) package, which replaces the default Next.js cache handler while preserving the original functionality of reading pre-rendered pages from the file system.
4+
5+
This particular example is designed to be self-hosted.
6+
7+
## How to use
8+
9+
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
10+
11+
```bash
12+
13+
npx create-next-app --example cache-handler-redis cache-handler-redis-app
14+
```
15+
16+
```bash
17+
yarn create next-app --example cache-handler-redis cache-handler-redis-app
18+
```
19+
20+
```bash
21+
pnpm create next-app --example cache-handler-redis cache-handler-redis-app
22+
```
23+
24+
Once you have installed the dependencies, you can begin running the example Redis Stack server by using the following command:
25+
26+
```bash
27+
docker-compose up -d
28+
```
29+
30+
Then, build and start the Next.js app as usual.
31+
32+
## Notes
33+
34+
- **Think different:** Ensure that your Redis server is operational and accessible before starting your Next.js application to prevent any connection errors. Remember to flush the cache or use namespacing if you preserve the Redis instance between builds.
35+
36+
- **Configure:** Add your Redis credentials to the provided `cache-handler-redis*` files. Learn more about connecting to Redis with Node.js [here](https://redis.io/docs/connect/clients/nodejs/).
37+
38+
- **Opt out of Redis during build if needed:**
39+
To build your Next.js app without connecting to Redis, wrap the `onCreation` callback with a condition as shown below:
40+
41+
```js
42+
if (process.env.SERVER_STARTED) {
43+
IncrementalCache.onCreation(() => {
44+
// Your code here
45+
})
46+
}
47+
```
48+
49+
This condition helps avoid potential issues if your Redis server is deployed concurrently with the app build.
50+
51+
- **Opt out file system reads, writes or both:**
52+
By default, the `@neshca/cache-handler` uses the file system to preserve the original behavior of Next.js, for instance, reading pre-rendered pages from the Pages dir. To opt out of this functionality, add the `diskAccessMode` option:
53+
54+
```js
55+
IncrementalCache.onCreation(() => {
56+
return {
57+
diskAccessMode: 'read-no/write-no', // Default is 'read-yes/write-yes'
58+
cache: {
59+
// The same cache configuration as in the example
60+
},
61+
}
62+
})
63+
```
64+
65+
This may be useful if you use only App dir and don't mind if Redis instance fails.
66+
67+
Provided `docker-compose.yml` is for local development only. It is not suitable for production use. Read more about [Redis installation](https://redis.io/docs/install/) and [management](https://redis.io/docs/management/) before deploying your application to production.
68+
69+
### How to clear the Redis cache
70+
71+
If you need to clear the Redis cache, use RedisInsight Workbench or run the following command:
72+
73+
```bash
74+
docker exec -it redis-stack redis-cli
75+
127.0.0.1:6379> flushall
76+
OK
77+
```
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { notFound } from 'next/navigation'
2+
import { CacheStateWatcher } from '../cache-state-watcher'
3+
import { Suspense } from 'react'
4+
import { RevalidateFrom } from '../revalidate-from'
5+
import Link from 'next/link'
6+
7+
type TimeData = {
8+
unixtime: number
9+
datetime: string
10+
timezone: string
11+
}
12+
13+
const timeZones = ['cet', 'gmt']
14+
15+
export const revalidate = 10
16+
17+
export async function generateStaticParams() {
18+
return timeZones.map((timezone) => ({ timezone }))
19+
}
20+
21+
export default async function Page({ params: { timezone } }) {
22+
const data = await fetch(
23+
`https://worldtimeapi.org/api/timezone/${timezone}`,
24+
{
25+
next: { tags: ['time-data'] },
26+
}
27+
)
28+
29+
if (!data.ok) {
30+
notFound()
31+
}
32+
33+
const timeData: TimeData = await data.json()
34+
35+
return (
36+
<>
37+
<header className="header">
38+
{timeZones.map((timeZone) => (
39+
<Link key={timeZone} className="link" href={`/${timeZone}`}>
40+
{timeZone.toUpperCase()} Time
41+
</Link>
42+
))}
43+
</header>
44+
<main className="widget">
45+
<div className="pre-rendered-at">
46+
{timeData.timezone} Time {timeData.datetime}
47+
</div>
48+
<Suspense fallback={null}>
49+
<CacheStateWatcher
50+
revalidateAfter={revalidate * 1000}
51+
time={timeData.unixtime * 1000}
52+
/>
53+
</Suspense>
54+
<RevalidateFrom />
55+
</main>
56+
<footer className="footer">
57+
<Link
58+
href={process.env.NEXT_PUBLIC_REDIS_INSIGHT_URL}
59+
className="link"
60+
target="_blank"
61+
rel="noopener noreferrer"
62+
>
63+
View RedisInsight &#x21AA;
64+
</Link>
65+
</footer>
66+
</>
67+
)
68+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
5+
type CacheStateWatcherProps = { time: number; revalidateAfter: number }
6+
7+
export function CacheStateWatcher({
8+
time,
9+
revalidateAfter,
10+
}: CacheStateWatcherProps): JSX.Element {
11+
const [cacheState, setCacheState] = useState('')
12+
const [countDown, setCountDown] = useState('')
13+
14+
useEffect(() => {
15+
let id = -1
16+
17+
function check(): void {
18+
const now = Date.now()
19+
20+
setCountDown(
21+
Math.max(0, (time + revalidateAfter - now) / 1000).toFixed(3)
22+
)
23+
24+
if (now > time + revalidateAfter) {
25+
setCacheState('stale')
26+
27+
return
28+
}
29+
30+
setCacheState('fresh')
31+
32+
id = requestAnimationFrame(check)
33+
}
34+
35+
id = requestAnimationFrame(check)
36+
37+
return () => {
38+
cancelAnimationFrame(id)
39+
}
40+
}, [revalidateAfter, time])
41+
42+
return (
43+
<>
44+
<div className={`cache-state ${cacheState}`}>
45+
Cache state: {cacheState}
46+
</div>
47+
<div className="stale-after">Stale in: {countDown}</div>
48+
</>
49+
)
50+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
*,
2+
*:before,
3+
*:after {
4+
box-sizing: border-box;
5+
}
6+
7+
body,
8+
html {
9+
margin: 0;
10+
padding: 0;
11+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
12+
color: #333;
13+
line-height: 1.6;
14+
background-color: #f4f4f4;
15+
}
16+
17+
.widget {
18+
background-color: #ffffff;
19+
border-radius: 8px;
20+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
21+
margin: 20px auto;
22+
padding: 20px;
23+
max-width: 600px;
24+
text-align: center;
25+
}
26+
27+
.pre-rendered-at,
28+
.cache-state,
29+
.stale-after {
30+
font-size: 0.9em;
31+
color: #666;
32+
margin: 5px 0;
33+
}
34+
35+
.cache-state.fresh {
36+
color: #4caf50;
37+
}
38+
39+
.cache-state.stale {
40+
color: #f44336;
41+
}
42+
43+
.revalidate-from {
44+
margin-top: 20px;
45+
}
46+
47+
.revalidate-from-button {
48+
background-color: #008cba;
49+
color: white;
50+
border: none;
51+
border-radius: 4px;
52+
padding: 10px 20px;
53+
cursor: pointer;
54+
transition: background-color 0.3s ease;
55+
}
56+
57+
.revalidate-from-button:hover {
58+
background-color: #005f73;
59+
}
60+
61+
.revalidate-from-button:active {
62+
transform: translateY(2px);
63+
}
64+
65+
.revalidate-from-button[aria-disabled='true'] {
66+
background-color: #ccc;
67+
cursor: not-allowed;
68+
}
69+
70+
.footer,
71+
.header {
72+
padding: 10px;
73+
position: relative;
74+
place-items: center;
75+
grid-auto-flow: column;
76+
bottom: 0;
77+
grid-gap: 20px;
78+
width: 100%;
79+
display: grid;
80+
justify-content: center;
81+
}
82+
83+
.link {
84+
color: #09f;
85+
text-decoration: none;
86+
transition: color 0.3s ease;
87+
}
88+
89+
.link:hover {
90+
color: #07c;
91+
}
92+
93+
@media (max-width: 768px) {
94+
.widget {
95+
width: 90%;
96+
margin: 20px auto;
97+
}
98+
99+
.footer {
100+
padding: 20px;
101+
}
102+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import './global.css'
2+
3+
export default function RootLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
return (
9+
<html lang="en">
10+
<body>{children}</body>
11+
</html>
12+
)
13+
}

0 commit comments

Comments
 (0)