Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a self-hosted shared cache example #58000

Merged
merged 4 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,14 @@ module.exports = {
}
```

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:
Then, create `cache-handler.js` in the root of your project, for example:

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

module.exports = class CacheHandler {
constructor(options) {
this.options = options
this.cache = {}
}

async get(key) {
Expand Down Expand Up @@ -190,7 +189,7 @@ module.exports = class CacheHandler {
}
```

Using a custom cache handler will allow you to ensure consistency across all pods hosting your Next.js application.
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.

> **Good to know:**
>
Expand Down
36 changes: 36 additions & 0 deletions examples/cache-handler-redis/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
77 changes: 77 additions & 0 deletions examples/cache-handler-redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Next.js Redis Cache Integration Example

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.

This particular example is designed to be self-hosted.

## How to use

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:

```bash

npx create-next-app --example cache-handler-redis cache-handler-redis-app
```

```bash
yarn create next-app --example cache-handler-redis cache-handler-redis-app
```

```bash
pnpm create next-app --example cache-handler-redis cache-handler-redis-app
```

Once you have installed the dependencies, you can begin running the example Redis Stack server by using the following command:

```bash
docker-compose up -d
```

Then, build and start the Next.js app as usual.

## Notes

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

- **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/).

- **Opt out of Redis during build if needed:**
To build your Next.js app without connecting to Redis, wrap the `onCreation` callback with a condition as shown below:

```js
if (process.env.SERVER_STARTED) {
IncrementalCache.onCreation(() => {
// Your code here
})
}
```

This condition helps avoid potential issues if your Redis server is deployed concurrently with the app build.

- **Opt out file system reads, writes or both:**
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:

```js
IncrementalCache.onCreation(() => {
return {
diskAccessMode: 'read-no/write-no', // Default is 'read-yes/write-yes'
cache: {
// The same cache configuration as in the example
},
}
})
```

This may be useful if you use only App dir and don't mind if Redis instance fails.

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.

### How to clear the Redis cache

If you need to clear the Redis cache, use RedisInsight Workbench or run the following command:

```bash
docker exec -it redis-stack redis-cli
127.0.0.1:6379> flushall
OK
```
68 changes: 68 additions & 0 deletions examples/cache-handler-redis/app/[timezone]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { notFound } from 'next/navigation'
import { CacheStateWatcher } from '../cache-state-watcher'
import { Suspense } from 'react'
import { RevalidateFrom } from '../revalidate-from'
import Link from 'next/link'

type TimeData = {
unixtime: number
datetime: string
timezone: string
}

const timeZones = ['cet', 'gmt']

export const revalidate = 10

export async function generateStaticParams() {
return timeZones.map((timezone) => ({ timezone }))
}

export default async function Page({ params: { timezone } }) {
const data = await fetch(
`https://worldtimeapi.org/api/timezone/${timezone}`,
{
next: { tags: ['time-data'] },
}
)

if (!data.ok) {
notFound()
}

const timeData: TimeData = await data.json()

return (
<>
<header className="header">
{timeZones.map((timeZone) => (
<Link key={timeZone} className="link" href={`/${timeZone}`}>
{timeZone.toUpperCase()} Time
</Link>
))}
</header>
<main className="widget">
<div className="pre-rendered-at">
{timeData.timezone} Time {timeData.datetime}
</div>
<Suspense fallback={null}>
<CacheStateWatcher
revalidateAfter={revalidate * 1000}
time={timeData.unixtime * 1000}
/>
</Suspense>
<RevalidateFrom />
</main>
<footer className="footer">
<Link
href={process.env.NEXT_PUBLIC_REDIS_INSIGHT_URL}
className="link"
target="_blank"
rel="noopener noreferrer"
>
View RedisInsight &#x21AA;
</Link>
</footer>
</>
)
}
50 changes: 50 additions & 0 deletions examples/cache-handler-redis/app/cache-state-watcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client'

import { useEffect, useState } from 'react'

type CacheStateWatcherProps = { time: number; revalidateAfter: number }

export function CacheStateWatcher({
time,
revalidateAfter,
}: CacheStateWatcherProps): JSX.Element {
const [cacheState, setCacheState] = useState('')
const [countDown, setCountDown] = useState('')

useEffect(() => {
let id = -1

function check(): void {
const now = Date.now()

setCountDown(
Math.max(0, (time + revalidateAfter - now) / 1000).toFixed(3)
)

if (now > time + revalidateAfter) {
setCacheState('stale')

return
}

setCacheState('fresh')

id = requestAnimationFrame(check)
}

id = requestAnimationFrame(check)

return () => {
cancelAnimationFrame(id)
}
}, [revalidateAfter, time])

return (
<>
<div className={`cache-state ${cacheState}`}>
Cache state: {cacheState}
</div>
<div className="stale-after">Stale in: {countDown}</div>
</>
)
}
102 changes: 102 additions & 0 deletions examples/cache-handler-redis/app/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
*,
*:before,
*:after {
box-sizing: border-box;
}

body,
html {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #333;
line-height: 1.6;
background-color: #f4f4f4;
}

.widget {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin: 20px auto;
padding: 20px;
max-width: 600px;
text-align: center;
}

.pre-rendered-at,
.cache-state,
.stale-after {
font-size: 0.9em;
color: #666;
margin: 5px 0;
}

.cache-state.fresh {
color: #4caf50;
}

.cache-state.stale {
color: #f44336;
}

.revalidate-from {
margin-top: 20px;
}

.revalidate-from-button {
background-color: #008cba;
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}

.revalidate-from-button:hover {
background-color: #005f73;
}

.revalidate-from-button:active {
transform: translateY(2px);
}

.revalidate-from-button[aria-disabled='true'] {
background-color: #ccc;
cursor: not-allowed;
}

.footer,
.header {
padding: 10px;
position: relative;
place-items: center;
grid-auto-flow: column;
bottom: 0;
grid-gap: 20px;
width: 100%;
display: grid;
justify-content: center;
}

.link {
color: #09f;
text-decoration: none;
transition: color 0.3s ease;
}

.link:hover {
color: #07c;
}

@media (max-width: 768px) {
.widget {
width: 90%;
margin: 20px auto;
}

.footer {
padding: 20px;
}
}
13 changes: 13 additions & 0 deletions examples/cache-handler-redis/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import './global.css'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Loading
Loading