In this tutorial, we’ll look into how we can implement a persistent dark mode for next.js applications. We’ll also be looking into how to maintain the theme between page reloads and also certain caveats that will arise due to this.
Please note that this tutorial assumes you have some experience working with Next.js previously. It also assumes you have a basic understanding of certain concepts such as getInitialProps
function, custom _app.js
and custom _document.js
files and their use cases. I have discussed about them briefly, but complete discussion is beyond the scope of this tutorial. I've also provided reference links for better understanding.
For the impatient, check out my github repo for this tutorial: https://github.com/abhi12299/next-dark-theme-toggle.
Here’s a quick look at what we’ll be making:
Now let’s get into the code. I’ll start by creating a simple Next.js project using create-next-app
. I’ll also use the starter code for custom-express-server
because we’ll be needing that. It’ll also save us some boilerplate code for the start.
npx create-next-app --example custom-server-express next-dark-toggler
We need a custom express
server for next.js because we want to intercept requests our server and set up cookie-parser
middleware which can parse cookies sent by the browser.
Start by installing the following dependencies:
yarn add js-cookie cookie-parser
Now head to server.js
file and replace everything with the following:
const express = require('express')
const next = require('next')
const cookieParser = require('cookie-parser')
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = express()
server.use(cookieParser()) // allows cookies to be accessed using req.cookies
server.all('*', (req, res) => {
return handle(req, res)
})
server.listen(port, (err) => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
With this in place, we can access cookies sent by the browser on our server. You’ll see why this is needed later.
Now make a file css/index.css
at the root of the project. Note that you’ll have to create the css/
folder. It’ll not be created by default. Paste the following content in there:
body.light {
--font-color: #000000;
--bg-color: #ffffff;
}
body.dark {
--font-color: #ffffff;
--bg-color: #000000;
}
body {
background-color: var(--bg-color);
transition: background-color 0.25s ease-in;
}
.container {
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: var(--font-color);
font-size: 30px;
font-family: monospace;
}
a {
cursor: pointer;
color: red;
text-decoration: underline;
}
There isn’t much going on here. Just some global CSS for our entire app. The main thing to note here is that we declare font and background color variables for the two modes of our application under the css selectors of body.dark
and body.light
. We’ll toggle the class light and dark on the body tag of our HTML document. Please note that this is a simple demo. In a real world scenario, you’ll want more than just these variables, such as accent, foreground and shadow colors.
Now all we need to do is add an appropriate class to the body tag of our server rendered application so that the theme persists for the user, even after reloading the page. This hints that we need a custom document for our app so that we can control the layout of our HTML document on the server itself. Next.js allows us to do that if we make a file pages/_document.js. Read more about custom document in next js here: https://nextjs.org/docs/advanced-features/custom-document.
The following will be the contents of pages/_document.js
file:
import Document, { Html, Head, Main, NextScript } from 'next/document'
import Cookies from 'js-cookie'
class MyDocument extends Document {
static async getInitialProps(ctx) {
let theme
if (ctx.req && 'cookies' in ctx.req) {
const { req } = ctx
theme = req.cookies.theme || 'light'
} else {
theme = Cookies.get('theme') === 'dark' ? 'dark' : 'light'
}
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps, theme }
}
render() {
const { theme } = this.props
return (
<Html>
<Head />
<body className={theme}>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
The function getInitialProps
is the most important part of the entire file. I’ll try to explain it as best as I can. When you request a webpage from a next.js server, it runs the getInitialProps
function once on the server for that particular page, if present. Afterwards, whenever you navigate to a new page in the application by clicking on the links in the app, next.js checks if that page we’re navigating to has a getInitialProps
. If it does, it calls the function. Note that this time it was run on the client side.
So the main takeaway is that it runs on the client as well as on the server.
We want this function in _document.js
because we want to look for a cookie by the name of theme
on the server so that we can add a class to the body tag accordingly. This function receives a context parameter which has a req
property which encapsulates the request sent to the server by the web browser. We already know that browsers send cookies to the server automatically with every request. Since this request first goes through the cookie-parser
middleware, we have access to the cookies property on the req
object. Also notice the if-else
block in the getInitialProps
function. That is needed because the req
property is only available on the context parameter when getInitialProps is run on the server. Otherwise we’ll have to extract cookies from the browser directly, which is handled by js-cookie
.
Whatever is returned by the getInitialProps
function is available to the component using props
. We extract the theme from props and set the body class accordingly in the render function.
But things get a little complicated here. According to next.js docs regarding _document.js
, https://nextjs.org/docs/advanced-features/custom-document#caveats, the third caveat states that getInitialProps
in _document.js
doesn’t run if a page is statically optimized. I've noticed inconsistent behaviour of getInitialProps
for such pages. We need to disable static optimization for every page in our application if we want this theme switching to be reliable, consistent and persistent. I’ve tried to find ways around this, but it doesn’t seem to work. I don’t know if this is the best way to do it. If you have any better solutions, I’d be more than happy to know. You can read more about static page optimization here: https://nextjs.org/docs/advanced-features/automatic-static-optimization.
In order to disable static page optimization, we need to have a getInitialProps/getServerSideProps
on every page of our application. But this can be done easily if we have a custom _app.js
file for our pages. You can read more about the custom app file here: https://nextjs.org/docs/advanced-features/custom-app. This app file encapsulates every page in our application and allows us to share common functionality between pages, such as layouts. There are many other use cases of this file, but we'll be using this for the reason mentioned before. For now, make a file pages/_app.js
with the following contents:
import App from 'next/app'
// import global css here
import '../css/index.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
MyApp.getInitialProps = async (appContext) => {
const appProps = await App.getInitialProps(appContext);
return { ...appProps }
}
export default MyApp
From here, it’s just smooth sailing. Here’s my pages/index.js
file. It’s pretty self explanatory.
import React from 'react'
import Link from 'next/link'
import Cookies from 'js-cookie'
const changeTheme = () => {
let newTheme
if (document.body.classList.contains('dark')) {
// toggle to light theme
document.body.classList.replace('dark', 'light')
newTheme = 'light'
} else {
// toggle to dark theme
document.body.classList.replace('light', 'dark')
newTheme = 'dark'
}
// set a cookie for ssr purposes
Cookies.set('theme', newTheme)
}
export default function Home() {
return (
<div className='container'>
<div onClick={changeTheme}>
Toggle Theme
</div>
<div>
<Link href='/a'>
<a>Click here to go to second page!</a>
</Link>
</div>
</div>
)
}
Just to show that this works across different pages, I made another file pages/a.js
.
import React from 'react'
import Router from 'next/router'
export default function A() {
return (
<div className='container'>
This is another page
<a onClick={Router.back}>Click here to go Back!</a>
</div>
)
}
Run the server with yarn dev
or npm run dev
and play around with the app. You should see everything works properly. If you have any questions/doubts, feel free to comment below.