Series: Personal/Professional Website
Sub-series: piratematt.com v3
Entry: 02—Upgrading to Next.js 13
First Published: July 2023
Plans never survive contact with reality
This wasn't supposed to be my second entry, but much like the Agile Manifesto I value responding to change over following a plan. Next.js released their next major version, Next.js 13. Things are different… How different is what this entry chronicles. Well… chronicles for a simple static site that is exported and served via github pages. If you're looking for a full comparison I suggest you start with their own blog post on version 13. It's where I started and I found it helpful.
The origin of the pivot
This upgrade journey began when I reached for Next.js to start a new project with a friend and was surprised by the app router. Caught flat footed I scrambled to explain a framework I no longer understood. Rather than simply reverting to an older version I was familiar with, we took some time to read through the docs and get the new lay of the land. Long story short, the project itself is on hold while my friend gets settled into a new job, so I switched my effort to understand Next.js 13 to my personal site. It certainly doesn't need updated, but I want the practice, so here we are.
The work ahead of me
Borrowing a technique from TDD (I'm still new-ish to TDD, but I've been making a concerted effort to practice it lately), I got started by creating a lightweight need/want TODO list. This enables me to give ideas the space they deserve as they crash into my brain, but in a way that lets me keep focus on the small step I'm trying to take when the crash occurs. My initial list was built from reading through the aforementioned blog post as well as the upgrade guide. These initial reads are done somewhere between a skimming pace and a studying pace. The goal isn't to recall everything, but to start to get a feel for the lay of the land. What should I look out for? Where can I read more about how to get XYZ working? Should I switch to the shiny new ABC way of doing things, or stick with what's already got working?
I'm not going to include my actual TODO list because it's not a good communication tool. It's a good working tool. It's constantly modified; items get added, removed, re-added, and re-removed all the time. You get the picture. To outline the work ahead of me I'll split things into two lists.
List 1: The differences in Next.js 13 I want to tackle in this effort.
- Switch to the new
next/font
. - Migrate from the pages router to the app router.
- Minimize my use of the
use client
directive (prioritize server side components).
List 2: A simplified overview of how this reflected in my TODO list.
- [ ] upgrade to latest Next.js version
- [ ] upgrade ESLint version
- [ ] upgrade to Built-in Font
- [ ] switch from pages to app router
- [ ] identify server vs client components and update appropriately
- [ ] rework how layouts work
- [ ] refactor
<Head>
components tometadata
exports - [ ] refactor from styled-components to CSS modules (styled-components require runtime javascript and therefore must be client components)
- [ ] refactor from pages/404.js to not-found.js file
Task 0 – Create a home for the effort and update to the latest versions
I wanted a safe isolated place in my github repository where I could tackle this update effort, so I kicked things off by creating a new branch:
git checkout -b next13
git push origin next13
Now I have a safe space I can frequently push my WIP (work in progress) states to just in case my local machine melts.
So… it turns out I'm already using Next.js 13, "next": "^13.1.6",
to be exact. What a twist! ref. How did I uncover this? As I was doing my first readthrough I saw they called out that <Link>
components no longer needed a child <a>
tag to function, and I thought to myself I've never had to do that to get <Link>
to work. So I took a quick peek at my package.json git history… and, yup, I've always been on Next.js 13. Fortunately for my sanity it's clear looking at the nextjs.org docs, that there has indeed been a recent switch to the app router, so my confusion wasn't merely the result of some fever dream. 🤞🏻being on Next 13 already makes my “upgrade” easier.
To get the latest and greatest for both Next.js and ESLint, I run:
npm i next@latest react@latest react-dom@latest eslint-config-next@latest
This puts me at the following version (extracted from package.json) 👇🏻
{
"dependencies": {
"next": "^13.4.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
},
"devDependencies": {
"eslint-config-next": "^13.4.7",
}
}
I then execute npm run lint
in my terminal and fix any reported issues (that aren't a future TODO list item).
Task 1 – Switch to the new next/font
and away from the @next/font
After a brief read through the Font Optimization section of the upgrade guide, I try the following:
// import { Share_Tech_Mono } from '@next/font/google';
import { Share_Tech_Mono } from 'next/font/google';
After a bit of manual checking, I realize this is all I need to do, so I also go ahead and uninstall the old @next/font
and delete the commented out import line.
npm uninstall @next/font
That was quick and painless! Another item checked off my TODO list! 🎉
Task 2 – Bye-bye “Pages” router, hello “App” router
I wish I could tell you this was an easy process with clear steps that you can follow in the same order I accomplished them in and succeed, but it ended up being more of an ever expanding FILO (first in, last out) stack of TODOs. I'll do my best to use headers to provide some clarity, but things may jump around a bit.
Task 2.1 – Refactor the home page to use the app router
Trying to take the smallest step I could, I set my targets on replacing the existing home page, pages/index.tsx
, with a “hello world” home page powered by the app router. To get started, I added an /app
directory and placed a hello world page.tsx
file within it.
// app/page.tsx
export const metadata = {
title: 'Home – piratematt.com'
};
export default function HomePage() => {
return (
<h1>Hello Home Page</h1>
);
}
Then I changed the name of my pages/index.tsx
to pages/old_index.tsx
to prevent conflicts, and restarted my dev server:
npm run dev
A quick navigation to http://localhost:3000/
showed me my shiny new “hello world” home page. Great! Things are moving along. Next I simply copy the contents of my old home page, styled-components and all. Optimistically I refresh my page. Uh oh, things are no longer working. None of the updates I made to _app.tsx
and _document.tsx
to get styled-components working are taking effect. Hmmm… time to add a new task to the stack.
Task 2.2 – Refactor _app.tsx
and _document.tsx
to the new app/layout.tsx
structure
Skipping a lot of narratively unnecessary trial and error let's just say this is where my desire to preserve my use of styled-components left me. I discovered each page/component leveraging styled-components needed the use client;
directive, which felt wrong because most of my page content is static “brochure content” and has no business not being rendered server-side. To that end, I elected to add another task to my quickly growing stack: refactor to CSS modules and prioritize server-side rendering. (Yes I realize that exporting this specific Next.js project makes it of little consequence in the end. Nevertheless, I saw value in practicing limiting my client-side components.)
Task 2.3 – Refactor styled-components (CSS-in-JS) to CSS modules
This is a task that gets repeatedly put back on the top of my task stack as I tackle pages one at a time. Rather than bore you with that repetitive process, I'll leave a sample of some salient details and move on. Overall the process was fairly simple. Rather than having styles declared inline within my .tsx
files, declarations are refactored/extracted to a global.css
file and a specific .module.css
file which are subsequently imported. If you want to read more, I suggest the Styling section of the upgrade docs.
Before
Global styles and themes were established in a config file in the root directory.
// themeConfig.js
export const theme = {
colors: {
bgDark: '#000000e6',
},
};
export const GlobalStyles = `
/* redacted for brevity */
`;
These global styles and themes are applied in the pages/_app.tsx
file.
// pages/_app.tsx
import { ThemeProvider } from 'styled-components'
import { GlobalStyles, theme } from '../themeConfig';
export default function App(/* redacted for brevity */) {
return (
<ThemeProvider theme={theme}>
<GlobalStyles />
{/* redacted for brevity */
</ThemeProvider>
);
}
Within each component “CSS-in-JS” is written leveraging string interpolation to access the theme variables where desired.
// Footer.tsx
import styled from 'styled-components';
const FooterWrapper = styled.div`
background: ${(theme) => theme.colors.bgDark};
`;
// Usage of `<FooterWrapper />` in component render redacted for brevity
After
Global CSS and theme variables are set in an app/global.css
file. Theme variables are set using the :root
pseudo-class selector to make them available everywhere.
/* app/global.css */
:root {
--bg--dark: #000000e6;
}
body {
/* redacted for brevity */
}
These globals are then included in the base layout of the app router. There is no need to specifically apply them via className
or any other method. This is taken care of for us by Next.
// app/layout.tsx
import './global.css'
// default export redacted for brevity
A .module.css
file is created for specific components/pages. It is best practice to use lowerCamelCase for class names as it is easier to dot-access them after they are imported into your component/page files.
/* components/Footer.module.css */
.footerWrapper {
background: var(--bg--dark);
}
This module specific CSS is imported into a styles
variable and then applied where desired by specifying a className
for whichever HTML tag or React component you want the styling to apply. Next handles unique class name generation, bundling, etc. all for us.
// components/Footer.tsx
import styles from './Footer.module.css';
export default function Footer() => {
return (
<div className={styles.footerWrapper}>
{/* redacted for simplicity */}
</div>
);
}
Pros – Overall, it's fine. Some things are harder/worse, sure, but I found no hills I was willing to die on.
- I used significantly less
use client;
directives to get my CSS-in-JS working. - There's no extra system to learn, it's normal CSS.
- Next handles all the hard stuff without customization: unique class name management, bundling, etc.
- CSS modules keep the CSS that's affecting your components alongside the components themselves and not hidden away in some massive CSS file.
Cons – Overall, CSS variables are less powerful than systems like SASS/Less/CSS-in-JS.
- You can't use CSS variables in media queries yet, so common breakpoints must be manually kept in sync.
- You can't nest selectors in CSS, necessitating more lines of CSS to do the same thing.
After I had fully refactored all components and pages to CSS modules I made sure to uninstall the no longer necessary styled-component packages and removed the pages
directory entirely. (Note: I made sure I had a commit to roll back to just in case I missed something. You might want to do the same if you're facing a similar migration effort.)
npm uninstall styled-components babel-plugin-styled-components @types/styled-components
At some point in my conversion process I found that my old way of applying my font needed modification to work with CSS modules, so I paused where I was and put another item on the top of the task stack.
Task 2.3.2 – Update font usage for CSS modules
Previously I was using a class name and importing the font into multiple files. Example 👇🏻:
// components/Footer.tsx
import { Share_Tech_Mono } from '@next/font/google';
import styles from './Footer.module.css';
export default function Footer() => {
return (
<div className={`${styles.footerWrapper} ${Share_Tech_Mono.className}`}>
{/* redacted for simplicity */}
</div>
);
}
Now I set a CSS variable, once, in the base layout of the app router and simply use it in whatever component/page CSS module file I want
// app/layout.tsx
const shareTechMono = Share_Tech_Mono({
weight: '400',
subsets: ['latin'],
variable: '--font--share-tech-mono',
fallback: ['monospace'],
});
export default function BaseLayout(/* redacted for brevity */) {
return (
<html lang="en" className={shareTechMono.variable}>
{/* redacted for brevity */}
</html>
);
};
/* components/Footer.module.css */
.footerWrapper {
font-family: var(--font--share-tech-mono, monospace), monospace;
}
The order of task stacking in this narrative and their actual order have well and truly diverged. For the sake of clarity we'll say my next discovery that made its way to the top of my stack arose during the conversion of my second page from the pages router and to the app router.
Task 2.4 – Refactor my second page to use the app router
I often find the first task I take on when refactoring to a new framework/pattern to be a relatively straightforward process. The issues only really start to rear their heads when I tackle a second task. Converting my second page was no exception. The first thing I noticed was that I had too many page.tsx
files open in my editor and that is a hill I'm willing to die on. It's hands down my least favorite commonly accepted pattern in all of development. Even one index.js
file irks me, because rarely is that file actually an index. Grrr. Anyways, I'll step away from the soapbox and digress.
Task 2.4.1 – Avoid page.tsx
hell
I wish I could remember who introduced me to this pattern that alleviates my frustrations, but alas I do not remember. The strategy itself is fairly simple. Take on a little bit of extra boilerplate now, so the rest of your time working in the codebase is significantly more enjoyable. Every page of mine adopts the following pattern. page.tsx
is pure boilerplate, and actual code is written in files that follow this pattern: UsefulName.page.tsx
. For instance, a page accessible at http://localhost:3000/second-page
would look something like👇🏻.
This boilerplate page:
// app/second-page/page.tsx
import SecondPage, { metadata as pageMetadata } from './Second.page';
export const metadata = { ...pageMetadata };
export default SecondPage;
Which serves the contents of this file where my actual code lives:
// app/second-page/Second.page.tsx`
export const metadata = {
title: 'Second – piratematt.com',
};
export default function SecondPage() {
/* redacted for brevity */
}
Note: I fully expect this boilerplate pattern to be rendered unnecessary by a future Next.js update and/or a configuration. I know I'm not the only developer willing to die on this hill. I spent some time digging around to see if it already existed, but I exhausted the amount I was willing to spend trying to alleviate my frustrations using “official” means and methods. Sometimes you have to know when to go with the sub-optimal solution you can do quickly and confidently over the optimal solution you don't yet know.
We’re once again departing from the actual order of events, but for the sake of the narrative let’s say this is when I came upon my next task for the top of the stack. My second page required a different layout than any of my other pages. Okay, it didn’t need a different layout, but I’m using one because I want to.
Task 2.4.2 – Handle multiple layouts; refactor away from the getLayout pattern
Previously I was using the page.getLayout
pattern to dynamically pull in a different layout for a single, on-off page. I could have duplicated layouts, but adhering to DRY (don't repeat yourself) seemed the wiser path. Within the app router, I failed to find a way to repeat my getLayout pattern. Instead I wound up refactoring my code structure to make use of route groups and several nested layout.tsx
files.
Oddly enough—considering my previous rant about page.tsx
hell—I adopted a slightly different pattern to prevent layout.tsx
hell. This is likely due to the fact that I only ever plan on having two layouts, but I knew I'd want to support n
pages. When things were all said and done, I ended up with 3 layout.tsx
files, 2 route groups, and 1 CommonLayout.tsx
component.
Before I dive into example code, let's talk briefly about route groups. Simply put, route groups are a special naming convention for a folder, (normal-layout)
for example, that enable us to logically group pages together without adding to the route's URL path. So app/(normal-layout)/about/page.tsx
is accessible at http://localhost:3000/about
and app/(abnormal-layout)/second-page/page.tsx
is accessible at http://localhost:3000/second-page
. With that out of the way, consider the following example code.
First we have the CommonLayout
component that is imported and utilized in every layout.tsx
file.
// app/CommonLayout.tsx
import NavHeader from '../components/NavHeader';
import Footer from '../components/Footer';
export default function CommonLayout({
abnormal,
children,
}: {
abnormal: boolean,
children: React.ReactNode,
}) {
return (
<>
<NavHeader abnormal={abnormal} />
<div>{children}</div>
<Footer abnormal={abnormal} />
</>
);
}
Next up we have the base layout that applies to every page the app router serves. You can see it's only doing the bare minimum, leaving the specifics of the layout up to each route group.
// app/layout.tsx
// styles, fonts, etc. redacted for brevity
export default function BaseLayout({
children,
}: {
children: React.ReactNode,
}) {
return (
<html lang="en">
<body>
{children}
</body>
</html>
);
};
Then we have our two route group layout.tsx
files, which are nearly identical, except for a single boolean switch.
// app/(normal-layout)/layout.tsx
import CommonLayout from '../CommonLayout';
export default function NormalLayout({
children,
}: {
children: React.ReactNode,
}) {
return (
<CommonLayout abnormal={false}>
{children}
</CommonLayout>
);
};
// app/(abnormal-layout)/layout.tsx
import CommonLayout from '../CommonLayout';
export default function AbnormalLayout({
children,
}: {
children: React.ReactNode,
}) {
return (
<CommonLayout abnormal={true}>
{children}
</CommonLayout>
);
};
With our two route groups now setup, our pages reside and are accessible at (note: I removed my Second.page.tsx pattern to simplify things):
- First/Home Page lives at
app/(normal-layout)/page.tsx
and is accessible athttp://localhost:3000
. - Second Page lives at
app/(abnormal-layout)/second-page/page.tsx
and is accessible athttp://localhost:3000/second-page
.
Originally I didn't even have app/layout.tsx
. I only had two layout files, one in each route group, but the next task we’re throwing on top of the stack made it a necessary refactor.
Task 2.5 – Refactoring pages/404.tsx
to app/not-found.tsx
The content of my 404/not-found page was a straightforward port. I simply moved the content from pages/404.tsx
to app/not-found.tsx
. Figuring out what level the layout needed abstracted to and how to minimize repeating myself took some trial and error. Long story short my base app/layout.tsx
doesn't do any of the actual layout lifting. As you can see above, that's all handled by my CommonLayout.tsx
file.
Previously I had something like this, leveraging the aforementioned getLayout
pattern:
// pages/404.tsx
import Layout, { PageType } from '../layouts/MainLayout';
const Page: PageType = function Page404 () {
return (
<>{/* redacted for brevity */}</>
);
}
Page.getLayout = function getLayout(page) {
return (
<Layout>{page}</Layout>
);
}
export default Page;
Now I have:
// app/not-found.tsx
import CommonLayout from './CommonLayout';
export default function Page404() {
return (
<CommonLayout abnormal={false}>
{/* redacted for brevity */}
</CommonLayout>
);
}
Task 2.6 – Refactoring <Head>
components to metadata
exports
If you have extra sharp eyes, you may have picked up on the way the app router handles page metadata when I was talking through my page.tsx
boilerplate. No longer will Next extract the <Head>
component from a page file. Instead you simply export a named metadata
object which Next ensures gets converted into header tags and included in the server response. If you attempt to export a metadata object from a client-rendered component/page with the use client;
directive, you'll find Next yells at you. You can only modify page metadata server-side.
Example, what previously looked like this:
// pages/about.tsx
export default function AboutPage () {
return (
<>
<Head>
<title>About - piratematt.com</title>
<description>A wall of text covering this {'"pirate"'} thing. Complete with a {'tl;dr'}.</description>
</Head>
<PageWithTitle title="about">
{/* redacted for brevity */}
</PageWithTitle>
</>
);
}
Now looks like this:
// app/about/page.tsx
export const metadata = {
title: 'About – piratematt.com',
description: 'A wall of text covering this "pirate" thing. Complete with a tl;dr.',
};
export default function AboutPage() {
/* redacted for brevity */
}
Take-aways & what's next for this series
I like what they're trying to do with Next.js 13 and the app router—be opinionated about structure to increase clarity. The necessity of the use client;
directive to leverage state, etc. makes it crystal clear what can be rendered server-side and what requires a client. When all was said and done, I had only a single component that needed the client directive. If it were up to me I'd require a UsefulName.page.tsx
pattern instead of just page.tsx
but I have a workaround I like well enough to continue using Next.
At the end of the day, if you leverage Next's upgrade guides you won't run into any show-stopping issues when updating to Next.js 13—at least not for a static brochure site. I suspect you may even come to prefer the changes by the end of your efforts, as I have.
What's next in this series? As a part of my upgrade efforts I've taken another look at my planned entries for the remainder of the series and cut it down quite a bit. Next's documentation already does a great job covering some of the topics I had planned, and for some of the others, I simply don't use the associated tools/technologies in this project anymore. Therefore, I've decided to preserve my writing time for other efforts. As it stands now here's the latest and greatest plan:
- [x] v3 – Overview & Getting Started
- [x] Upgrading to Next.js 13 (this entry!)
- [ ] Deploying Next.js to Github Pages
- (considering) Final thoughts, conclusions, and future ideas.
If you've made it this far, thanks for reading! If you have comments, critiques, cautions, or clairvoyances please feel welcome to share them!
Top comments (0)