Skip to content

Commit

Permalink
Add a self-hosted shared cache example
Browse files Browse the repository at this point in the history
  • Loading branch information
better-salmon committed Nov 7, 2023
1 parent d3756fa commit eaee86a
Show file tree
Hide file tree
Showing 15 changed files with 619 additions and 0 deletions.
36 changes: 36 additions & 0 deletions examples/self-hosted-shared-cache/.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
45 changes: 45 additions & 0 deletions examples/self-hosted-shared-cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Example of the self-hosted app with Redis as cache storage

This example demonstrates using Redis as a shared cache for hosting multiple instances of a Next.js app. It supports caching at both the App and Pages routes in default and standalone modes, as well as Partial Pre-rendering. This functionality is made possible by the [`@neshca/cache-handler`](https://github.com/caching-tools/next-shared-cache/tree/canary/packages/cache-handler) package, which offers an API to customize cache handlers and seamlessly replace Next.js' default cache.

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 self-hosted-shared-cache self-hosted-shared-cache-app
```

```bash
yarn create next-app --example self-hosted-shared-cache self-hosted-shared-cache-app
```

```bash
pnpm create next-app --example self-hosted-shared-cache self-hosted-shared-cache-app
```

Specify the Redis connection string using the `REDIS_URL` environment variable. This can be a URL or a connection string formatted as `redis[s]://[[username][:password]@][host][:port][/db-number]`. Learn more about connecting to Redis with Node.js [here](https://redis.io/docs/connect/clients/nodejs/).

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

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 [maganement](https://redis.io/docs/management/) before deploying your application to production.

### Cache of your application lives outside of the Next.js app

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

```bash
docker exec -it redis-stack redis-cli
127.0.0.1:6379> flushall
OK
```
7 changes: 7 additions & 0 deletions examples/self-hosted-shared-cache/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use server'

import { revalidateTag } from 'next/cache'

export default async function revalidate() {
revalidateTag('time-data')
}
50 changes: 50 additions & 0 deletions examples/self-hosted-shared-cache/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 after: {countDown}</div>
</>
)
}
98 changes: 98 additions & 0 deletions examples/self-hosted-shared-cache/app/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
*,
*: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 {
text-align: center;
padding: 10px;
position: relative;
bottom: 0;
width: 100%;
color: white;
}

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

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

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

.footer {
padding: 20px;
}
}
13 changes: 13 additions & 0 deletions examples/self-hosted-shared-cache/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>
)
}
55 changes: 55 additions & 0 deletions examples/self-hosted-shared-cache/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use server'

import { notFound } from 'next/navigation'
import { CacheStateWatcher } from './cache-state-watcher'
import { formatTime } from '../utils/format-time'
import { Suspense } from 'react'
import { RevalidateFrom } from './revalidate-from'
import Link from 'next/link'

type TimeData = {
unixtime: number
}

const revalidate = 10

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

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

const timeData: TimeData = await data.json()

const unixTimeMs = timeData.unixtime * 1000

return (
<>
<main className="widget">
<div className="pre-rendered-at">
Pre-rendered at {formatTime(unixTimeMs)}
</div>
<Suspense fallback={null}>
<CacheStateWatcher
revalidateAfter={revalidate * 1000}
time={unixTimeMs}
/>
</Suspense>
<RevalidateFrom />
</main>
<footer className="footer">
<Link
href={process.env.NEXT_PUBLIC_REDIS_INSIGHT_URL}
className="external-link"
target="_blank"
rel="noopener noreferrer"
>
View RedisInsight &#x21AA;
</Link>
</footer>
</>
)
}
26 changes: 26 additions & 0 deletions examples/self-hosted-shared-cache/app/revalidate-from.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import { useFormStatus } from 'react-dom'
import revalidate from './actions'

function SubmitButton() {
const { pending } = useFormStatus()

return (
<button
className="revalidate-from-button"
type="submit"
aria-disabled={pending}
>
Revalidate
</button>
)
}

export function RevalidateFrom() {
return (
<form className="revalidate-from" action={revalidate}>
<SubmitButton />
</form>
)
}
Loading

0 comments on commit eaee86a

Please sign in to comment.