Today I will be writing about my experience migrating a Next.js 12 code-base to Next.js 14. I wrote the original code back in 2021 and continued to maintained it until early 2024. I would have continue using Next 12 if not for the issue of Vercel sending me notifications to stop using it and just migrate.
Reasons to Migrate to Next 14
Next 12 is old and there is a new shiny thing ie. Next 14. React latest features such as react server components are not compatible and cannot be used Next 12. Vercel wants to move past the primitives introduced by Next 12 such as server side-rendering, and static props.
Another reason to migrate to Next 14 is to re-learn the new structures, patterns, format, and paradigms that were introduced with Next 13 and continued on Next 14. I waited a new release to finish the migration or start it rather, as I did not wanted to learn the latest and greatest to then have to scraped.
How was my experience
Earlier this year 2024 I set out to update Something To Do. I took some time to learn the new features of the framework, and looked through
different Next 13, and 14 repos to understand the new structure and
how server-components are used. After I understood the basics I
proceeded to just update everything on the Something To Do repo and
see what breaks… to my surprise everything did break, I was using
some tools that while were nice to have on Next 12 on Next 14 were
unnecessary, such as, next-pwa, next-seo, server side rendering (Not
to be confused with React server components), some other tools that I
stopped using just because I was not taking full advantage of were
Styled Components, PostCSS, and Husky.
Package.json file
After learning that everything broke, and that I had over-engineered my Web-App. I proceeded to slowly update all my dependencies while doing this is I learned that I had to remove and rethink my code away from next-pwa, next-seo, and server side rendering of next 12. So I choose a somewhat of a lazy route: created a new project with next 14.
In these package json files we will see the how many dependencies I got rid off on my new project. Some of them are because Next 14 has them included such as SEO, and Metadata, while others are just because I was a bit lazy to download them and set them up again at the time as I wanted to ship the Web-App as fast as possible.
Next 12 Package.json
{ "name": "curated-by", "description": "This website was created with the purpose of better organizing all my saved places on google maps, and so that I can more easily find the places that i want to go or have been.", "version": "1.0.0", "private": true, "author": "Ronny Coste <contact@ronnycoste.com> (@costeronny)", "keywords": [ "nextjs", "google-maps-api", "react", "personal-tools", "pwa", "typescript" ], "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "build-stats": "cross-env ANALYZE=true npm run build", "export": "next export", "build-prod": "run-s clean build export", "clean": "rimraf .next out", "lint": "next lint", "build-types": "tsc --noEmit --pretty", "prepare": "husky install" }, "dependencies": { "@react-google-maps/api": "^2.12.2", "@react-google-maps/marker-clusterer": "^2.11.8", "@types/eslint": "^8.21.3", "@types/google-map-react": "^2.1.7", "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", "d3": "^7.6.1", "next": "^14.0.4", "next-pwa": "^5.5.5", "next-seo": "^5.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", "styled-components": "^5.3.5", "vercel": "^32.7.1" }, "devDependencies": { "@fullhuman/postcss-purgecss": "^4.1.3", "@next/bundle-analyzer": "^12.1.0", "@types/node": "^17.0.23", "@types/react": "^17.0.42", "@types/styled-components": "^5.1.24", "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^5.16.0", "autoprefixer": "^10.4.4", "cross-env": "^7.0.3", "cssnano": "^5.1.5", "eslint": "^8.11.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^16.1.4", "eslint-config-next": "^12.1.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-tailwindcss": "^3.5.0", "eslint-plugin-unused-imports": "^2.0.0", "husky": "^7.0.4", "lint-staged": "^12.3.7", "npm-run-all": "^4.1.5", "postcss": "^8.4.12", "postcss-preset-env": "^7.4.3", "prettier": "^2.6.0", "rimraf": "^3.0.2", "tailwindcss": "^3.0.23", "typescript": "^4.6.2" }, "resolutions": { "@types/react": "17.0.14", "@types/react-dom": "17.0.14" }, "license": "MIT" }
Next 14 package.json
{ "name": "something-to-do", "description": "This website was created with the purpose of better organizing all my saved places on google maps, and so that I can more easily find the places that i want to go or have been.", "version": "1.1.0", "private": true, "author": "Ronny Coste <contact@ronnycoste.com> (@costeronny)", "keywords": [ "nextjs", "google-maps-api", "react", "personal-tools", "pwa", "typescript" ], "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@radix-ui/react-icons": "^1.3.0", "@react-google-maps/api": "^2.19.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "d3": "^7.9.0", "lucide-react": "^0.363.0", "next": "14.1.4", "react": "^18", "react-dom": "^18", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@types/eslint": "^8.56.6", "@types/node": "^20.11.30", "@types/react": "^18.2.69", "@types/react-dom": "^18", "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.4", "postcss": "^8", "tailwindcss": "^3.3.0", "typescript": "^5" } }
Going this route allowed me to more easily understand all my breaking code and how I needed to rethink it, also it allowed me to rethink my folder structure from the ground up without any confusing migration
patterns.
After moving and updating all my breaking code to the new project, I proceeded to quickly improve my “vintage code”.
Folder structure
The folder structure took me a minute to figure out… I don’t know why now that I know how it works but at the time I was so involved with Next 12 that the new pattern of Next 14 did not make a lot of sense to me at the time. But if I am being honest I think I prefer the next 14 pattern it looks a bit more tidy, and easier to changes to specific pages.
Handling of pages
The ways pages are handled and how routing works on Next 14, is completely different to how it used to work on Next 12. It moved from a file-name based routing to a folder based routing schema. This way of handling routing is a little bit easier to maintain and also it makes it easier to have page specific ways of handling things, like page specific errors with different icons and what not.
Home page
The main difference in the index or home files is the lack of needing to tell the server what kind of props it needs. Ex; no need to use “GetStaticProps” anymore as the default is using the server and be converted to HTML, unless we explicitly tell the framework we want to use the client to render our pages by using the “use client” primitive.
Next 12
// index.tsx import React from 'react'; import MyMaps from '@/components/maps/Maps'; import { Meta } from '@/layout/Meta'; import { Main } from '@/templates/Main'; const Index = () => { return ( <Main meta={ <Meta title="Curated By" description="This website was created with the purpose of better organizing all my saved places on google maps, and so that I can more easily find the places that i want to go or have been." /> } > <MyMaps /> </Main> ); }; export default Index; export const getStaticProps = async () => { return { props: {}, }; };
Next 14
// page.tsx // 'use client'; import React from 'react'; import Head from "next/head"; import MyMaps from '@/components/maps/Maps'; // import { Meta } from '@/layout/Meta'; // import Main from '@/app/template'; import Template from '@/templates/template'; export default function Home() { return ( <> <Head> <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon={`{"token": "${process.env.NEXT_PUBLIC_ANALYTICS_ID}"}`} ></script> <link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" /> <link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" /> <link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" /> </Head> <Template> <MyMaps /> </Template> </> ); };
Handling of metadata
On Next 12 I was using a third-party tool to handle all the metadata. Vercel and the Next 12 team used that library in many examples so I just went with it for my needs and it worked perfectly but now on Next 14 all of the metadata primitive is included in the framework with the metadata primitive which is what I am now using.
Next-seo
// Meta.tsx import { Locale } from 'next/dist/compiled/@vercel/og/satori'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; import { AppConfig } from '@/utils/AppConfig'; type IMetaProps = { canonical: string | undefined; URL: string | URL; siteName: string; title?: string; description?: string; themeColor?: string; backgroundColor?: string; og: { locale?: Locale; type?: 'website'; ogImage: string | URL; width?: number; height?: number; }; twitter: { card?: string; site?: string; }; }; const Meta = (props: IMetaProps, customMeta) => { const router = useRouter(); const meta = { title: 'Curated By', description: 'An idea to better use my data starting with Google Maps.', image: '/icon-512x512.png', type: 'website', URL: 'somethingto.do', siteName: 'Something To Do', // themeColor: '##F5E1E6', // backgroundColor: '#F5E1E6', og: { locale: 'en-US', type: 'website', ogImage: '/icon-512x512.png', width: 512, height: 512, }, twitter: { card: 'summary_large_image', site: 'Something To Do', }, ...customMeta, }; return ( <> <Head> <title>{meta.title}</title> <meta name="robots" content="follow, index" /> <meta name="description" content={meta.description} /> <meta property="og:url" content={`https://ronnycoste.com${router.asPath}`} /> <link rel="canonical" href={`https://ronnycoste.com${router.asPath}`} /> <meta property="og:type" content={meta.type} /> <meta property="og:site_name" content="Ronny Coste" /> <meta property="og:description" content={meta.description} /> <meta property="og:title" content={meta.title} /> <meta property="og:image" content={meta.image} /> <meta name="twitter:card" content="Curated By Ronny Coste" /> <meta name="twitter:site" content="@costeronny" /> <meta name="twitter:title" content={meta.title} /> <meta name="twitter:description" content={meta.description} /> <meta name="twitter:image" content={meta.image} /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="shortcut icon" href="/favicon.ico" /> <meta charSet="UTF-8" key="charset" /> <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, height=95vh,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=yes" key="viewport" /> <link rel="apple-touch-icon" href={`${router.basePath}/apple-touch-icon.png`} key="apple" /> <meta name="Curated By" content="Curated By" /> <meta name="theme-color" content="#000" /> <link rel="manifest" href="/manifest.json" /> <link rel="icon" href={`${router.basePath}/favicon.ico`} key="favicon" /> <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon={`{"token": "${process.env.NEXT_PUBLIC_ANALYTICS_ID}"}`} ></script> </Head> <NextSeo title={props.title} description={props.description} canonical={props.canonical} openGraph={{ title: props.title, description: props.description, url: props.canonical, locale: AppConfig.locale, site_name: AppConfig.site_name, }} /> </> ); }; export { Meta };
Next Metadata
// layout.tsx import type { Viewport, Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#ffffff" }; export const metadata: Metadata = { title: 'Something To Do', description: 'An idea to better use my data starting with Google Maps.', metadataBase: new URL("https://somethingto.do/"), openGraph: { siteName: "Something To Do", type: "website", locale: "en_US" }, robots: { index: true, follow: true, "max-image-preview": "large", "max-snippet": -1, "max-video-preview": -1, googleBot: "index, follow" }, applicationName: "Something To Do", appleWebApp: { title: "Something To Do", statusBarStyle: "default", capable: true }, icons: { icon: [ { url: "/favicon.ico", type: "image/x-icon" }, { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" } // add favicon-32x32.png, favicon-96x96.png, android-chrome-192x192.png ], shortcut: [ { url: "/favicon.ico", type: "image/x-icon" } ], apple: [ { url: "/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, { url: "/apple-icon-60x60.png", sizes: "60x60", type: "image/png" } // add apple-icon-72x72.png, apple-icon-76x76.png, apple-icon-114x114.png, apple-icon-120x120.png, apple-icon-144x144.png, apple-icon-152x152.png, apple-icon-180x180.png ] }, twitter: { card: 'summary_large_image', site: 'Something To Do', }, }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={inter.className}>{children}</body> </html> ); }
PWA Apps
The same that happened for the metadata primitive also happened to PWA. Now those settings, batteries and primitives are included within the framework by default as long as their is a manifest file in your public folder defining everything.
Next-PWA
// next.config.js /* eslint-disable import/no-extraneous-dependencies */ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ eslint: { dirs: ['.'], }, poweredByHeader: false, trailingSlash: true, basePath: '', // The starter code load resources from `public` folder with `router.basePath` in React components. // So, the source code is "basePath-ready". // You can remove `basePath` if you don't need it. reactStrictMode: true, }); // const withPWA = require('next-pwa'); const withPWA = require('next-pwa')({ dest: 'public', // disable: process.env.NODE_ENV === 'development', register: true, // scope: '/app', // sw: 'service-worker.js', }); module.exports = withPWA({ // ...before // pwa: { // dest: 'public', // register: true, // skipWaiting: true, // disable: process.env.NODE_ENV === 'development', // }, });
no dependency needed anymore
// next.config.mjs /** @type {import('next').NextConfig} */ const nextConfig = {}; export default nextConfig;
Where all this changes necessary? I don’t really think so, while I appreciate getting new features, security patches and better developer experience, now I am starting to appreciate slower moving frameworks, and ecosystems. Because at the end of the day this app for me is just a personal tool that does not have any user input and all I needed for is it to tell me my places saved on the JSON object to be shown on the map, and list.
This is not to say that I do not like Nextjs, I do and migrating wasn’t really bad it was just an afternoon of my time to get done and learn the new features of the framework. I also think that most of these changes improves our webapps at the end of the day, as you need less third-parties on your tooling thus improving the load times of our apps.