How to create a Next.js PWA with TypeScript, Mantine and Custom Fonts (Optional)

Mehul Kundu ✦Mehul Kundu ✦
11 min read

Introduction

I have been using this stack for a long time. I once attempted to create a template repository to use for every new project, but this was not feasible due to the rapid growth of libraries. The template would quickly become outdated. Therefore, I decided to document every step for myself. Later, I realized that publishing this documentation could be useful to others. Hence, I am writing this blog post. Eventually, I will make a video about this stack, as it is my primary medium of expression. Let's get started!

GitHub set up

To begin, create a GitHub (or any other git manager) account and create a repository. Then, clone the repository onto your development machine.

It's important to give the repository an appropriate name, as the next project will use the same name in the package.json file.

Create Next App

Open the folder in the terminal and use

yarn create next-app --ts .

Follow along with the instructions

√ Would you like to use ESLint with this project? ... Yes
√ Would you like to use `src/` directory with this project? ... Yes
√ Would you like to use experimental `app/` directory with this project? ... No
√ What import alias would you like configured? » @

Prettier Setup (Optional)(Recommended)

This step is going to make your life so much easier while coding

Create a .prettierignore file in the root directory

# Add files here to ignore them from prettier formatting

/dist
/coverage

Create a .prettierrc file in the root directory

#Add prettier rules here

{
  "singleQuote": true
}

Making it PWA

Installing next-pwa

Installing next-pwa package will first and foremost task because it handles everything else.

In the root folder

yarn add next-pwa

Creating manifest.json

There can be 2 ways that you can follow

  • Go to simicart and fill out the details.

  • Upload your app logo (that needs to be at least 512x512 px2)

  • Make sure the Display is standalone

  • Click on generate

It will generate a .zip file for you. After unzipping the file you will get

icon-192x192.png
icon-256x256.png
icon-384x384.png
icon-512x512.png
manifest.webmanifest

Copy all of the files into public folder then rename the manifest.webmanifest file to manifest.json

Using my template (what I do)

  • Create a manifest.json file in the public folder

  • A .png (which is 1080 x 1080 px2) and .svg copy of the logo to the folder I use this .png file for everything, as a favicon as well as in the manifest.

My friend Vasanth and I were discussing PWAs when he mentioned that modern websites are faster than before. With caching, it only takes milliseconds to load a logo. Therefore, we can use PWAs without worrying.

  • paste this code on manifest.json

      {
          "theme_color": "#fff",
          "background_color": "#fff",
          "display": "standalone",
          "scope": "/",
          "start_url": "/",
          "name": "App Name | App Tagline",
          "short_name": "App Name",
          "description": "App Description",
          "orientation": "portrait",
          "display_mode": "fullscreen",
          "icons": [
                      {
                  "src": "/icon.png",
                  "sizes": "192x192",
                  "type": "image/png"
              },
              {
                  "src": "/icon.png",
                  "sizes": "256x256",
                  "type": "image/png"
              },
              {
                  "src": "/icon.png",
                  "sizes": "384x384",
                  "type": "image/png"
              },
              {
                  "src": "/icon.png",
                  "sizes": "512x512",
                  "type": "image/png"
              }
          ],
          "theme_color_in_manifest": true,
          "background_color_in_manifest": true,
          "display_in_manifest": true,
          "scope_in_manifest": true,
          "start_url_in_manifest": true,
          "name_in_manifest": true,
          "short_name_in_manifest": true,
          "description_in_manifest": true,
          "orientation_in_manifest": true,
          "display_mode_in_manifest": true,
          "icons_in_manifest": true
      }
    

Making Changes in _document.ts

  • Go to _document.ts file under pages directory

  • Make changes in the <Head> tag I have changed self-closing <Head/> tag into a normal tag to include manifest data

      import Document, { Html, Head, Main, NextScript } from "next/document";
    
      class MyDocument extends Document {
        render() {
          return (
            <Html>
              <Head>
                <link rel="icon" href="/icon.png" />
                <link rel="manifest" href="/manifest.json" />
                <link rel="apple-touch-icon" href="/icon.png"></link>
                <meta name="theme-color" content="#fff" />
              </Head>
              <body>
                <Main />
                <NextScript />
              </body>
            </Html>
          );
        }
      }
    
      export default MyDocument;
    

Putting auto-generated files into .gitignore

Though this will be effective after the next step, still I’m putting it before the next step because there are multiple times I have forgotten to do this

  • Go to you .gitignore file and add the code

      # PWA files
      **/public/sw.js
      **/public/workbox-*.js
      **/public/worker-*.js
      **/public/sw.js.map
      **/public/workbox-*.js.map
      **/public/worker-*.js.map
    
  • Later on, we will see where it is coming from

Configuring PWA in Next Config

  • Go to next.config.js file in the root directory and add this code

      /** @type {import('next').NextConfig} */
      const runtimeCaching = require('next-pwa/cache');
    
      const withPWA = require('next-pwa')({
        dest: 'public',
        register: true,
        skipWaiting: true,
        runtimeCaching,
        buildExcludes: [/middleware-manifest.json$/],
      });
      const nextConfig = {
        reactStrictMode: true,
      };
    
      module.exports = withPWA(nextConfig);
    
  • Run your code, you’ll be able to see that your PWA is running

  • Now you’ll be able to see the file that we put into .gitignore in the previous step is generated in the public folder.

  • But that’s not it. For the development environment, you should disable PWA as it generates too much console.log and you can easily get lost in it while resolving other code issues.

  • So go to next.config.js and add this line in your code disable: process.env.NODE_ENV === 'development'

      /** @type {import('next').NextConfig} */
      const runtimeCaching = require('next-pwa/cache');
    
      const withPWA = require('next-pwa')({
        dest: 'public',
        register: true,
        skipWaiting: true,
        runtimeCaching,
        buildExcludes: [/middleware-manifest.json$/],
        disable: process.env.NODE_ENV === 'development',
      });
      const nextConfig = {
        reactStrictMode: true,
      };
    
      module.exports = withPWA(nextConfig);
    
  • Now if you re-run your code, you’ll be able to see it in the console PWA disabled

  • But still, you can run your app in PWA mode as you’ve installed the “shell” already.

  • Just click on this icon and choose your installed shell

  • Our PWA step is done

Adding Mantine

Basic Mantine setup is easy, though I’ll suggest you go to the Mantine docs and follow as it may change in the future as Mantine is a rapidly growing component library with frequent updates and upgrades now and then.

  • Install Mantine dependencies

      yarn add @mantine/core @mantine/hooks @mantine/next @emotion/server @emotion/react
    
  • 2 extra dependencies that you need to add

      yarn add cookies-next @tabler/icons-react
    
  • Update _document.tsx file

      import { createGetInitialProps } from '@mantine/next';
      import Document, { Head, Html, Main, NextScript } from 'next/document';
    
      const getInitialProps = createGetInitialProps();
    
      export default class _Document extends Document {
        static getInitialProps = getInitialProps;
    
        render() {
          return (
            <Html>
              <Head>
                          <link rel="icon" href="/icon.png" />
                <link rel="manifest" href="/manifest.json" />
                <link rel="apple-touch-icon" href="/icon.png"></link>
                <meta name="theme-color" content="#fff" />
              </Head>
              <body>
                <Main />
                <NextScript />
              </body>
            </Html>
          );
        }
      }
    
  • Update _app.tsx

      import { AppProps } from 'next/app';
      import Head from 'next/head';
      import { MantineProvider } from '@mantine/core';
    
      export default function App(props: AppProps) {
        const { Component, pageProps } = props;
    
        return (
          <>
            <Head>
              <meta charSet="utf-8" />
              <meta
                name="viewport"
                content="minimum-scale=1, initial-scale=1, width=device-width"
              />
            </Head>
    
            <MantineProvider
              withGlobalStyles
              withNormalizeCSS
              theme={{
                /** Put your mantine theme override here */
                colorScheme: 'light',
              }}
            >
              <Component {...pageProps} />
            </MantineProvider>
          </>
        );
      }
    

Adding Logo, Layout and Theme Toggle (Bonus)

Logo as a re-usable component

It is really helpful to create your logo as a component, as it will be used throughout your app in multiple places.

  • Create a Logo.tsx file in your components folder

      import Image from 'next/image';
      import React from 'react';
    
      type Props = {
        size?: number;
      };
    
      function Logo({ size }: Props) {
        return <Image src="/icon.svg" alt="App Name Logo" width={size} height={size} />;
      }
    
      export default Logo;
    
  • Remember earlier in this tutorial I added an .svg variant of my logo into a public folder and now I’m using it in this component as next/image

  • As I’m using a square (or any 1:1) logo, I can set my width and height same. But depending upon your logo it can vary. Though you can use width and height standalone as long as your .svg logo has viewBox in it. It will maintain the ratio.

Layout

In this demonstration, I will showcase the layout using the AppShell component of Mantine as it is the most effective one.

To achieve maximum flexibility in layout, I always adhere to a specific folder structure where the content of each section of the AppShell is placed in a separate folder. Although this may be overly complex for some applications, I find it useful in reducing the amount of code in a single file, thereby improving the DX. To implement this, I create a Layout directory in the component folder.

Layout
|--AsideBar
|        |--index.tsx
|--FooterNav
|        |--index.tsx
|--HeaderNav
|        |--index.tsx
|--Navigation
|        |--index.tsx
|        |--ToggleTheme.tsx
|--index.tsx

Depending on the layout of the AppShell, the ToggleTheme may be placed in different folders. Here, I am showing the alt layout. However, it can be created separately to be placed anywhere in the app, like on Facebook or Twitter, where changing the theme requires more effort. However, that is not our goal here. The code is as follows:

ToggleTheme.tsx

import React from 'react';
import { ActionIcon, useMantineColorScheme } from '@mantine/core';
import { IconSun, IconMoonStars } from '@tabler/icons-react';

type Props = {};

function ToggleTheme({}: Props) {
  const { colorScheme, toggleColorScheme } = useMantineColorScheme();
  const dark = colorScheme === 'dark';

  return (
    <ActionIcon
      radius="md"
      variant="outline"
      color={dark ? 'yellow' : 'blue'}
      onClick={() => toggleColorScheme()}
      title="Toggle color scheme"
    >
      {dark ? <IconSun size={18} /> : <IconMoonStars size={18} />}
    </ActionIcon>
  );
}

export default ToggleTheme;

index.tsx in AsideBar, FooterNav, HeaderNav, Navigation

These four codes are primarily the same; the only changes needed are the name of the export component and the content in the file.

import React from 'react';

type Props = {};

function AsideBar({}: Props) {
  return <>This is a side AsideBar</>;
}

export default AsideBar;

index.tsx in Layout

import {
  AppShell,
  Aside,
  Box,
  Burger,
  Container,
  Divider,
  Footer,
  Group,
  Header,
  MediaQuery,
  Navbar,
  ScrollArea,
  Space,
  Text,
  Title,
} from '@mantine/core';
import React, { useState } from 'react';
import Logo from '../Logo';
import AsideBar from './AsideBar';
import FooterNav from './FooterNav';
import HeaderNav from './HeaderNav';
import Navigation from './Navigation';
import ToggleTheme from './Navigation/ToggleTheme';

type Props = {
  children: React.ReactNode;
};

function Layout({ children }: Props) {
  const [opened, setOpened] = useState(false);
  return (
    <AppShell
      layout="alt"
      navbarOffsetBreakpoint="sm"
      asideOffsetBreakpoint="sm"
      navbar={
        <Navbar
          hiddenBreakpoint="sm"
          hidden={!opened}
          width={{ sm: 200, lg: 320 }}
        >
          <Navbar.Section>
            <Container
              p="md"
              sx={(theme) => ({
                borderBottom: `1px solid ${
                  theme.colorScheme === 'dark'
                    ? theme.colors.dark[4]
                    : theme.colors.gray[2]
                }`,
                height: '66px',
              })}
            >
              <Group position="apart">
                <Group spacing="xs">
                  <Logo size={32} />
                  <Group spacing={0}>
                    <Title order={3} fz={24} fw={700}>
                      finstahq
                    </Title>
                  </Group>
                </Group>
                <Group>
                  <ToggleTheme />
                  <MediaQuery largerThan="sm" styles={{ display: 'none' }}>
                    <Burger
                      opened={opened}
                      onClick={() => setOpened((o) => !o)}
                      size="sm"
                      mr="xl"
                    />
                  </MediaQuery>
                </Group>
              </Group>
            </Container>
          </Navbar.Section>
          <Navbar.Section grow component={ScrollArea} px="md">
            <Navigation />
          </Navbar.Section>
          <Navbar.Section>
            <Container
              p="md"
              sx={(theme) => ({
                borderTop: `1px solid ${
                  theme.colorScheme === 'dark'
                    ? theme.colors.dark[4]
                    : theme.colors.gray[2]
                }`,
                height: '66px',
              })}
            ></Container>
          </Navbar.Section>
        </Navbar>
      }
      aside={
        <MediaQuery smallerThan="sm" styles={{ display: 'none' }}>
          <Aside hiddenBreakpoint="sm" width={{ sm: 200, lg: 320 }}>
            <Aside.Section>
              <Container
                p="md"
                sx={(theme) => ({
                  borderBottom: `1px solid ${
                    theme.colorScheme === 'dark'
                      ? theme.colors.dark[4]
                      : theme.colors.gray[2]
                  }`,
                  height: '66px',
                })}
              ></Container>
            </Aside.Section>
            <Aside.Section grow component={ScrollArea} p="md">
              <AsideBar />
            </Aside.Section>
            <Aside.Section>
              <Container
                p="md"
                sx={(theme) => ({
                  borderTop: `1px solid ${
                    theme.colorScheme === 'dark'
                      ? theme.colors.dark[4]
                      : theme.colors.gray[2]
                  }`,
                  height: '66px',
                })}
              ></Container>
            </Aside.Section>
          </Aside>
        </MediaQuery>
      }
      header={
        <Header height={66}>
          <MediaQuery largerThan="sm" styles={{ display: 'none' }}>
            <Burger
              opened={opened}
              onClick={() => setOpened((o) => !o)}
              size="sm"
              mr="xl"
            />
          </MediaQuery>
          <Container p="md">
            <HeaderNav />
          </Container>
        </Header>
      }
      footer={
        <Footer height={66}>
          <FooterNav />
        </Footer>
      }
    >
      {children}
    </AppShell>
  );
}

export default Layout;

Updating _app.tsx

import { useState } from 'react';
import { AppProps } from 'next/app';
import { GetServerSidePropsContext } from 'next';
import {
  ColorSchemeProvider,
  ColorScheme,
  MantineProvider,
} from '@mantine/core';
import { getCookie, setCookie } from 'cookies-next';

import '../styles/globals.css';
import Head from 'next/head';

export default function App(props: AppProps & { colorScheme: ColorScheme }) {
  const { Component, pageProps } = props;

  const [colorScheme, setColorScheme] = useState<ColorScheme>(
    props.colorScheme
  );

  const toggleColorScheme = (value?: ColorScheme) => {
    const nextColorScheme =
      value || (colorScheme === 'dark' ? 'light' : 'dark');
    setColorScheme(nextColorScheme);
    // when color scheme is updated save it to cookie
    setCookie('mantine-color-scheme', nextColorScheme, {
      maxAge: 60 * 60 * 24 * 30,
    });
  };

  return (
    <>
      <Head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="minimum-scale=1, initial-scale=1, width=device-width"
        />
      </Head>
      <ColorSchemeProvider
        colorScheme={colorScheme}
        toggleColorScheme={toggleColorScheme}
      >
        <MantineProvider
          withGlobalStyles
          withNormalizeCSS
          theme={{
            /** Put your mantine theme override here */
            colorScheme,
            fontFamily: 'IBM Plex Mono, mono space',
            headings: { fontFamily: 'IBM Plex Sans, sans-serif' },
          }}
        >
          <Component {...pageProps} />
        </MantineProvider>
      </ColorSchemeProvider>
    </>
  );
}

App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => ({
  // get color scheme from cookie
  colorScheme: getCookie('mantine-color-scheme', ctx) || 'light',
});

I’m using cookies to save the color theme preference of the user here.

As well as using a custom font for the Heading and Body. To use a custom font just add the font link in <Head> of the _document.tsx

        <link
            rel="preconnect"
            href="<https://api.fonts.coollabs.io>"
            // crossorigin
          />
          <link
            href="<https://api.fonts.coollabs.io/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap>"
            rel="stylesheet"
          />

Coollabs fonts are a great, privacy-friendly Google Fonts CDN alternative. Check it out.

Now use your Layout anywhere

import Layout from '@/components/Layout';
import Head from 'next/head';

export default function Home() {
  return (
    <>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
      </Head>
      <Layout>
        <h1>Hello World</h1>
      </Layout>
    </>
  );
}

Conclusion

It's great to see how easy it is to create a custom layout in Next.js using Mantine and Coollabs fonts. By using cookies to save the user's color theme preference, we can provide a personalized experience. Plus, Coollabs fonts are a privacy-friendly alternative to Google Fonts. Overall, this setup allows for a clean and professional-looking website with a touch of customization.

Indeed! It's important to remember that a good layout is not just about aesthetics but also about usability and accessibility. By using Mantine, we can ensure that the website will be accessible to all users, regardless of their abilities or the device they're using. Additionally, using custom fonts can enhance the readability of the website and make the content more engaging.

Overall, creating a custom layout with Mantine and Coollabs fonts is a great way to add a personal touch to your Next.js website while also ensuring accessibility and usability.

So that’s it. Bye ✌🏼

10
Subscribe to my newsletter

Read articles from Mehul Kundu ✦ directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mehul Kundu ✦
Mehul Kundu ✦

✦ Product Designer foyer.work Maker of Merlin - merlin.foyer.work ✦ Core team fueler.io ✦ Building bohon.co