Some things I learnt upgrading to NextJS 13 - pages to app what can possibly go wrong?

Some things I learnt upgrading to NextJS 13 - pages to app what can possibly go wrong?

Upgrading to NextJS 13

·

23 min read

I have a side project closed source in early stage development, it has been running nicely on NextJS 12 but as things go, there is a new shiny version so naturally I wanted to use that!

The following are some of my learning's migrating from old to new with NextJS framework, also included, is a collection of resources used a long the way.

I am generally not a great technical writer, I always end up with the TLDR; version, so I apologize for that now. Writing is something I am actively trying to improving on as a software engineer, hence this is why I am starting a blog, thanks hashnode!

This is a hindsight reflection on how things went down, so please ask any questions as I have surely left things out on my path to upgrade.


Jargon translation - words, acronyms

  • TLDR = too long don't read
  • Programming language = They are a kind of computer language
  • Library = A collection of pre-written code
  • Framework = abstraction which can provide generic functionality
  • Javascript = ¿programming language?
  • React = Javascript Library
  • NextJS = react framework
  • Closed Source = not available for public use
  • Client-side = refers to operations that are performed by the client(essentially the web browser)
  • Server-side = refers to operations that are performed by the server, (a different computer to the one you are using the app on)
  • UI = user interface (the stuff you look at, aka the most important part of the app)
  • Web app = an application that runs inside your web browser aka: app
  • Software engineer = Someone who writes gibberish across a computer screen all day, and then analyses why what has been written is not yet as good as it could be
  • Stack trace = A list of things the application was doing when an Exception(error) happened
  • Component = An independent and reusable bit of code
  • Use client = An instruction for when building the app that explains that this component should be used Client side
  • Leaf = is a reference to the location of a leaf on a tree, usually being the most outer part of the tree, in reference to this app the trunk of the tree is everything inside the app folder.

Resources

  1. NextJS 13 blog
  2. [Feedback] App Directory in Next.js 13
  3. Your Next.js 13 upgrade guide
  4. Routing
  5. Layout component
  6. Data fetching
  7. How to Build a Fullstack App with Next.js, Prisma, and PostgreSQL
  8. Getting started with Next.js, TypeScript, and Stripe Checkout

Project

How it started How its going
Screenshot 2022-11-13 at 10.26.37 am.png Screenshot 2022-11-13 at 10.28.04 am.png

Nothing outrageous there, no refactoring... I know you all want to see what is in the app folder, so here it is.


The app folder

Screenshot 2022-11-13 at 10.37.03 am.png


Learning's so far, potential points of interest

  • I only have one layout.tsx (server-side), this is because I have a PageTemplate component that handles some general styling throughout my app. This could change as I get more familiar with layout.tsx components. check out the code section below to see what *PageTemplate looks like.
  // single flex col with marginX
  return (
      <PageTemplate>
        <Title headerType="h1Title" title="Add a your Details" />

        <AddDetailsForm />
      </PageTemplate>
  )
  // tripple flex col
  return (
      <PageTemplate
        fixedLeftContent={
          [<div className="text-black">left content</div>]
        }
        fixedRightContent={
          [<div className="text-black">right content</div>]
        }
      >
        <Title
          headerType="h1Title"
          title="Update a your Profile"
        />

        <UpdateProfileForm />
      </PageTemplate>
  )
  • I only use one error.tsx(client-side), this is because previously I used a pretty standard errorBoundary component, it handles all major UI failures, this code now lives inside error.tsx. As I get more comfortable with error.tsx and needing better stack trace I am sure this will change.
  • All Pages except the list/page.tsx are client-side, I will need to move the 'use client' to the leaf components as I continue work on this app, this will help lift the performance of the site, and better inform the developer where client-side interactive components live.
  • For now I have offloaded payment responsibility to stripe for payment through url, the pages/api, handles that redirect
  • Next auth is handled through pages/api also, essentially following the above resource How to Build a Fullstack App with Next.js, Prisma, and PostgreSQL with some tweaking here and there.
  • Prisma client and usage, essentially followed the above recourse How to Build a Fullstack App with Next.js, Prisma, and PostgreSQL with some tweaking here and there
  • tailwindcss I didn't notice any changes for adding tailwindcss to the project.

Pros & Cons of upgrade

Pros

  • Having a constant reminder to write code that is composition over abstraction, the reminder is the new folder/file convention.
  • Requesting data where it is needed in a simple way and now only one way.
  • Flexibility to implement different patterns for folder/file management outside of app folder per team or application requirements.
  • New and easier way to add fonts to the project
  • Remove <head> node noise from page, it now lives in layout.tsx
  • Better developer experience
    • Reduced code abstraction
    • Reduced need for context providers
    • Reduced duplication of tailwindcss utility styling throughout app
    • Better Image component implementation
    • Overall better developer experience
    • Single focus when working on UI, API, App composition, related to developer experience
    • No more custom server stuff, this was always possible, but it just feel more permanent now, your head space is either with in app/components or api/prisma

Cons

  • pages/api I was very ready to get rid of pages folder, I never really liked it. I think api folder can be a first class citizen just like app.
  • Learning curve of server side and client side working in tandem, not specific to NextJS, just my limited knowledge so far.
  • Have to update all Link components to no longer contain <a> elements, or use a legacy version of Link.

Code snippets

I have added some code snippets to reference, hopefully adding some context to all the words above.

rootLayout

  import React, { ReactNode } from 'react'
  import { Iceland } from '@next/font/google'
  import CustomContextMenu from '../components/contextMenu'
  import BootstrapContext from '../context/bootstrapContext'
  import Footer from '../components/common/footer'

  import '../styles/globals.css'

  const iceland = Iceland({
    weight: '400',
    style: 'normal',
    subsets: ['latin'],
  })

  export default function RootLayout({ children }: { children: ReactNode }) {
    return (
      <html lang="en" dir="ltr" className={iceland.className}>
        <head>
          <title>The Shop</title>
          <meta name="description" content="buy and sell merchandise online" />
          <link rel="icon" href="/favicon.ico" />

          <link rel="manifest" href="/site.webmanifest" />
          <link
            rel="sitemap"
            type="application/xml"
            title="Sitemap"
            href="/sitemap.xml"
          />
          <link rel="home" href="/" />

          <link rel="canonical" href="https://www..." />
          <link
            rel="apple-touch-icon"
            sizes="180x180"
            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="manifest" href="/site.webmanifest" />
          <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
          <link rel="icon" href="/favicon.ico" />
        </head>
        <body className="font-theme">
          <BootstrapContext>{children}</BootstrapContext>
          <Footer />
          <CustomContextMenu />
        </body>
      </html>
    )
  }

PageTemplate

  import React, {
    FC,
    Children,
    cloneElement,
    ReactNode,
    ReactElement,
  } from 'react'
  // NOTE:: PageContent just provides "padding: 1rem"
  import PageContent from '../common/pageContent'

  type PageTemplateProps = {
    children: ReactNode | ReactNode[]
    fixedLeftContent?: ReactNode | ReactNode[]
    fixedRightContent?: ReactNode | ReactNode[]
  }
  // TODO:: maybe update hiding showing side columns based on count remove css.
  const PageTemplate: FC<PageTemplateProps> = (props: PageTemplateProps) => {
    const { children, fixedLeftContent, fixedRightContent } = props

    const fixedSideContentWidth =
      fixedRightContent && fixedLeftContent
        ? 'w-fixed lg:w-1/4 md:w-1/4 w-full flex-shrink flex-grow-0'
        : 'w-fixed lg:w-1/2 md:w-1/2 w-full flex-shrink flex-grow-0'

    return (
      <>
        <div className="w-full flex flex-col sm:flex-row flex-wrap sm:flex-nowrap pb-4 flex-grow">
          <div
            className={`${
              !fixedLeftContent || Children.count(fixedLeftContent) === 0
                ? 'hidden'
                : fixedSideContentWidth
            }`}
          >
            <div className="sticky w-full h-full justify-center text-center items-center">
              <PageContent>
                {Children.count(fixedLeftContent) > 0
                  ? Children.map(fixedLeftContent, (child: ReactElement) =>
                      cloneElement(child),
                    )
                  : null}
              </PageContent>
            </div>
          </div>
          <main role="main" className="w-auto flex-grow">
            <PageContent>
              {Children.count(children) === 1
                ? children
                : Children.map(children, (child: ReactElement) =>
                    cloneElement(child),
                  )}
            </PageContent>
          </main>
          <div
            className={`${
              !fixedRightContent || Children.count(fixedRightContent) === 0
                ? 'hidden'
                : fixedSideContentWidth
            }`}
          >
            <div className="flex sm:flex-col justify-center text-center items-center">
              <PageContent>
                {Children.count(fixedRightContent) > 0
                  ? Children.map(fixedRightContent, (child: ReactElement) =>
                      cloneElement(child),
                    )
                  : null}
              </PageContent>
            </div>
          </div>
        </div>
      </>
    )
  }

  PageTemplate.defaultProps = {
    fixedLeftContent: undefined,
    fixedRightContent: undefined,
  }

  export default PageTemplate

error.tsx

  'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'

interface ErrorBoundaryProps {
  error: Error
  reset: () => void
}

interface Error {
  message: string
}

const __DEV__: boolean =
  !process.env.NODE_ENV || process.env.NODE_ENV === 'development'

function AppErrorBoundary({ error, reset }: ErrorBoundaryProps) {
  const [hasError, setHasError] = useState<boolean>(false)

  useEffect(() => {
    if (error) {
      setHasError(true)
      // Log the error to an error reporting service
      console.error(error)
    }
  }, [error])

  return (
    <div className="flex items-center justify-center w-screen h-screen bg-gradient-to-r from-indigo-600 to-blue-400">
      <div className="px-40 py-20 bg-white rounded-md shadow-xl">
        <div className="flex flex-col items-center">
          <h1 className="font-bold text-blue-600 text-9xl capitalize">Error</h1>
          <h6 className="mb-2 text-2xl font-bold text-center text-gray-800 md:text-3xl">
            <span className="text-red-500">Oops! </span>
            This wasn&apos;t meant to happen! 😫😢
          </h6>
          <p className="mb-8 text-center text-gray-500 md:text-lg">
            Sorry for any inconvenience. If you&apos;re seeing this often,
            please
            <Link href="/">contact us</Link>
          </p>
          <Link
            href="/"
            className="px-6 py-2 text-sm font-semibold text-blue-800 bg-blue-100"
          >
            Take me home
          </Link>
          {__DEV__ && (
            <button onClick={() => reset()}>Reset error boundary</button>
          )}
        </div>
      </div>
    </div>
  )
}

export default AppErrorBoundary

list/page.tsx

  import prisma from '../../../../lib/prisma'
  import { MerchandiseApi } from '../../../../pages/api/merchandiseApi'
  import { formatDate } from '../../../../utils/helpers/commonFunctions'
  import CardGrid from '../../../../components/cardGrid'
  import Title from '../../../../components/common/title'
  import SearchBanner from '../../../../components/searchBanner'
  import PageTemplate from '../../../../components/pageTemplate'

  async function getData() {
    const data = await prisma.merchandise.findMany({
      include: {
        seller: { include: { user: true } },
        shop: true,
        item: true,
        itemImages: { include: { image: true } },
      },
    })

    const merchandise: MerchandiseApi = {
      merchandise: data.map(item => {
        return {
          ...item,
          creationDate: formatDate(item.creationDate, 'readableShortDate'),
        }
      }),
    }
    return merchandise
  }

  export default async function PropertiesPage() {
    const data = await getData()

    return (
      <>
        <SearchBanner
          bannerImage=""
          bannerHasSearch
          showPropertyTypeFilters
          width="w-full mx-auto transition-width transition-slowest ease"
        />
        <PageTemplate
          fixedLeftContent={[<div className="text-black">left content</div>]}
          fixedRightContent={[<div className="text-black">right content</div>]}
        >
          <Title
            headerType="h1Title"
            containerClassName="text-center"
            title="Merchandise result page"
          />

          <CardGrid data={data.merchandise} />

        </PageTemplate>
      </>
    )
  }

stripe

  import { Stripe, loadStripe } from '@stripe/stripe-js'

  let stripePromise: Promise<Stripe | null>
  const getStripe = () => {
    if (!stripePromise) {
      stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
    }
    return stripePromise
  }

  export default getStripe
  'use client'
  import { useEffect } from 'react'
  import { loadStripe } from '@stripe/stripe-js'
  import { usePathname } from 'next/navigation'
  import { PrimaryButton } from '../common/button'

  // Make sure to call `loadStripe` outside of a component’s render to avoid
  // recreating the `Stripe` object on every render.
  const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)

  const StripPaymentButton = () => {
    useEffect(() => {
      // Check to see if this is a redirect back from Checkout
      const query = new URLSearchParams(window.location.search)
      if (query.get('success')) {
        console.log('Order placed! You will receive an email confirmation.')
      }

      if (query.get('canceled')) {
        console.log(
          'Order canceled -- continue to shop around and checkout when you’re ready.',
        )
      }
    }, [])

    const pathname = usePathname()
    console.log(
      '🚀 ~ file: index.tsx ~ line 21 ~ StripPaymentButton ~ pathname',
      pathname,
    )

    return (
      <form
        action="/api/checkout_sessions"
        method="POST"
        className="relative w-full"
      >
        <PrimaryButton
          width="lg:w-max md:w-max w-full"
          height="h-full"
          buttonPadding="p-1"
          type="submit"
          role="link"
          textColour="text-gray-700"
          borderRadius="rounded-sm"
          backgroundColor="bg-green-300"
          containerClassName="absolute lg:right-2 right-0 lg:-top-64 md:-top-56 -top-24 lg:w-auto md:w-auto w-full"
        >
          Purchase
        </PrimaryButton>
      </form>
    )
  }

  export default StripPaymentButton

pages/api/checkout_sessions

  import { NextApiRequest, NextApiResponse } from 'next'
  import Stripe from 'stripe'

  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    // https://github.com/stripe/stripe-node#configuration
    apiVersion: '2022-08-01',
  })

  const stripeHandler = async (
    req: NextApiRequest,
    res: NextApiResponse,
  ): Promise<void> => {
    if (req.method === 'POST') {
      try {
        // Create Checkout Sessions from body params.
        const session = await stripe.checkout.sessions.create({
          submit_type: 'pay',
          payment_method_types: ['card'],
          line_items: [
            {
              // Provide the exact Price ID (for example, pr_1234) of the product you want to sell
              price: 'YOUR_PRICE_CODE_FOR_THIS_ITEM,
              quantity: 1,
            },
          ],
          mode: 'payment',
          success_url: `${req.headers.origin}/?success=true`,
          cancel_url: `${req.headers.origin}/?canceled=true`,
          automatic_tax: { enabled: true },
        })
        res.redirect(303, session.url)
      } catch (err) {
        res.status(err.statusCode || 500).json(err.message)
      }
    } else {
      // TODO:: do i need 'OPTIONS' here also?
      res.setHeader('Allow', 'POST')
      res.status(405).end('Method Not Allowed')
    }
  }

  export default stripeHandler

pages/api/auth/[...nextauth].ts

  const handleNavToListMyProperty = useCallback(
    (event: MouseEvent<HTMLButtonElement>) => {
      event.preventDefault()
      router.push(!session ? '/api/auth/signin' : '/create-user')
    },
    [session],
  )
  import { NextApiHandler } from 'next'
  import NextAuth from 'next-auth'
  import { PrismaAdapter } from '@next-auth/prisma-adapter'
  import GitHubProvider from 'next-auth/providers/github'
  import prisma from '../../../lib/prisma'

  const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options)
  export default authHandler

  const options = {
    providers: [
      GitHubProvider({
        clientId: process.env.GITHUB_ID,
        clientSecret: process.env.GITHUB_SECRET,
      }),
    ],
    adapter: PrismaAdapter(prisma),
    secret: process.env.SECRET,
  }

Prisma

  import { PrismaClient } from '@prisma/client'

  let prisma: PrismaClient

  if (process.env.NODE_ENV === 'production') {
    prisma = new PrismaClient()
  } else {
    if (!global.prisma) {
      global.prisma = new PrismaClient()
    }
    prisma = global.prisma
  }

  export default prisma

client-side api POST example: pages/api/add-new-item

  const newItem = await fetcher(
    `${endPoint}/add-new-item`,
    payload,
    'POST',
  )
  export const fetcher = async <T>(
    endpointUrl: string,
    payload?: T | Record<string, string> | string,
    method = 'GET',
  ): Promise<T> => {
    const response = await fetch(endpointUrl, {
      method,
      headers: {
        'content-type': 'application/json;charset=UTF-8',
      },
      body: JSON.stringify(payload),
    })

    const responseData = await response.json()

    if (response.ok) {
      if (responseData) {
        return responseData
      } else {
        return Promise.reject(new Error())
      }
    } else {
      const error = new Error(
        responseData.errors?.map((e: Error) => e.message).join('\n') ?? 'unknown',
      )
      return Promise.reject(error)
    }
  }
  import { NextApiRequest, NextApiResponse } from 'next'
  import prisma from '../../../lib/prisma'
  import { ItemDetails } from '../itemDetails'

  const itemDetailsHandler = async (
    req: NextApiRequest,
    res: NextApiResponse,
  ): Promise<void> => {
    const {
      body,
      // query,
      method,
    } = req

    switch (method) {
      case 'POST':
        const newData: ItemDetails = {
          ...body,
        }

        try {
          const data = await prisma.item.create({
            data: {
              sometablecolumn: newData.value
              ...
            },
          })

          const newItem: ItemDetails = data

          return res.status(200).json(newItem)

          // return res.send(payload);
        } catch (error: any) {
          // TODO:: dispatch error to redux here maybe?
          // throw error for errorWrapper to handle?
          // send to some error logging service?
          throw error
        }

      default:
        res.setHeader('Allow', ['OPTIONS'])
        res.status(405).end(`Method ${method} Not Allowed`)
    }
  }

  export default itemDetailsHandler

tailwindcss

  /* TODO:: import any custom fonts here */

  /* import/add tailwind styles to project */
  @import "tailwindcss/base";
  @import "./custom-base.css";

  @import "tailwindcss/components";
  @import "./custom-components.css";

  /* import any npm dependency styles here under "tailwindcss/components" to make sure they are added as "custom" styling */
  @import "tailwindcss/utilities";
  /* @import "custom-utilities.css"; */
/** @type {import('tailwindcss').Config} */

module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  presets: [],
  darkMode: 'media', // or 'class'
  variants: {
    extend: {
      opacity: ['disabled'],
    },
  },
  theme: {
    extend: {
      transitionProperty: {
        width: 'width',
      },
      zIndex: {
        '-1': '-1',
      },
      transformOrigin: {
        0: '0%',
      },
      animation: {
        loader: 'loader 0.6s infinite alternate',
      },
      keyframes: {
        loader: {
          from: {
            opacity: 1,
            transform: 'translate3d(0, -1rem, 0)',
          },
          to: {
            opacity: 0.1,
          },
        },
      },
      fontFamily: {
        script: ["'Nanum Brush Script', cursive"],
        theme: ["'Chillax-Regular', sans"],
      },
    },
    screens: {
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px',
    },
    colors: ({ colors }) => ({
      inherit: colors.inherit,
      current: colors.current,
      transparent: colors.transparent,
      black: colors.black,
      white: colors.white,
      slate: colors.slate,
      gray: colors.gray,
      zinc: colors.zinc,
      neutral: colors.neutral,
      stone: colors.stone,
      red: colors.red,
      orange: colors.orange,
      amber: colors.amber,
      yellow: colors.yellow,
      lime: colors.lime,
      green: colors.green,
      emerald: colors.emerald,
      teal: colors.teal,
      cyan: colors.cyan,
      sky: colors.sky,
      blue: colors.blue,
      indigo: colors.indigo,
      violet: colors.violet,
      purple: colors.purple,
      fuchsia: colors.fuchsia,
      pink: colors.pink,
      rose: colors.rose,



      'primary-colour': '#fb5607',
      'secondary-colour': '#ff006e',
      'tertiary-colour': '#3a86ff',
      'secondary-contrast-colour': '#ffbe0b',
      'contrast-colour': '#8338ec',

      'theme-black': '#121212',
      'theme-grey': '#535353', 
    }),
    columns: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
      '3xs': '16rem',
      '2xs': '18rem',
      xs: '20rem',
      sm: '24rem',
      md: '28rem',
      lg: '32rem',
      xl: '36rem',
      '2xl': '42rem',
      '3xl': '48rem',
      '4xl': '56rem',
      '5xl': '64rem',
      '6xl': '72rem',
      '7xl': '80rem',
    },
    spacing: {
      px: '1px',
      0: '0px',
      0.5: '0.125rem',
      1: '0.25rem',
      1.5: '0.375rem',
      2: '0.5rem',
      2.5: '0.625rem',
      3: '0.75rem',
      3.5: '0.875rem',
      4: '1rem',
      5: '1.25rem',
      6: '1.5rem',
      7: '1.75rem',
      8: '2rem',
      9: '2.25rem',
      10: '2.5rem',
      11: '2.75rem',
      12: '3rem',
      14: '3.5rem',
      16: '4rem',
      20: '5rem',
      24: '6rem',
      28: '7rem',
      32: '8rem',
      36: '9rem',
      40: '10rem',
      44: '11rem',
      48: '12rem',
      52: '13rem',
      56: '14rem',
      60: '15rem',
      64: '16rem',
      72: '18rem',
      80: '20rem',
      96: '24rem',
    },
    animation: {
      none: 'none',
      spin: 'spin 1s linear infinite',
      ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
      pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
      bounce: 'bounce 1s infinite',
      fade: 'fadeIn 2s ease',
    },
    aspectRatio: {
      auto: 'auto',
      square: '1 / 1',
      video: '16 / 9',
    },
    backdropBlur: ({ theme }) => theme('blur'),
    backdropBrightness: ({ theme }) => theme('brightness'),
    backdropContrast: ({ theme }) => theme('contrast'),
    backdropGrayscale: ({ theme }) => theme('grayscale'),
    backdropHueRotate: ({ theme }) => theme('hueRotate'),
    backdropInvert: ({ theme }) => theme('invert'),
    backdropOpacity: ({ theme }) => theme('opacity'),
    backdropSaturate: ({ theme }) => theme('saturate'),
    backdropSepia: ({ theme }) => theme('sepia'),
    backgroundColor: ({ theme }) => ({
      ...theme('colors'),
      'primary-colour': '#fb5607',
      'secondary-colour': '#ff006e',
      'contrast-colour': '#8338ec',
    }),
    backgroundImage: {
      none: 'none',
      'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))',
      'gradient-to-tr':
        'linear-gradient(to top right, var(--tw-gradient-stops))',
      'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))',
      'gradient-to-br':
        'linear-gradient(to bottom right, var(--tw-gradient-stops))',
      'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))',
      'gradient-to-bl':
        'linear-gradient(to bottom left, var(--tw-gradient-stops))',
      'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))',
      'gradient-to-tl':
        'linear-gradient(to top left, var(--tw-gradient-stops))',
    },
    backgroundOpacity: ({ theme }) => theme('opacity'),
    backgroundPosition: {
      bottom: 'bottom',
      center: 'center',
      left: 'left',
      'left-bottom': 'left bottom',
      'left-top': 'left top',
      right: 'right',
      'right-bottom': 'right bottom',
      'right-top': 'right top',
      top: 'top',
    },
    backgroundSize: {
      auto: 'auto',
      cover: 'cover',
      contain: 'contain',
    },
    blur: {
      0: '0',
      none: '0',
      sm: '4px',
      DEFAULT: '8px',
      md: '12px',
      lg: '16px',
      xl: '24px',
      '2xl': '40px',
      '3xl': '64px',
    },
    brightness: {
      0: '0',
      50: '.5',
      75: '.75',
      90: '.9',
      95: '.95',
      100: '1',
      105: '1.05',
      110: '1.1',
      125: '1.25',
      150: '1.5',
      200: '2',
    },
    borderColor: ({ theme }) => ({
      ...theme('colors'),
      DEFAULT: theme('colors.gray.200', 'currentColor'),
    }),
    borderOpacity: ({ theme }) => theme('opacity'),
    borderRadius: {
      none: '0px',
      sm: '0.125rem',
      DEFAULT: '0.25rem',
      md: '0.375rem',
      lg: '0.5rem',
      xl: '0.75rem',
      '2xl': '1rem',
      '3xl': '1.5rem',
      full: '9999px',
    },
    borderWidth: {
      DEFAULT: '1px',
      0: '0px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    boxShadow: {
      sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
      DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
      md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
      lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
      xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
      '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
      inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
      none: 'none',
    },
    boxShadowColor: ({ theme }) => theme('colors'),
    caretColor: ({ theme }) => theme('colors'),
    accentColor: ({ theme }) => ({
      ...theme('colors'),
      auto: 'auto',
    }),
    contrast: {
      0: '0',
      50: '.5',
      75: '.75',
      100: '1',
      125: '1.25',
      150: '1.5',
      200: '2',
    },
    container: {},
    content: {
      none: 'none',
    },
    cursor: {
      auto: 'auto',
      default: 'default',
      pointer: 'pointer',
      wait: 'wait',
      text: 'text',
      move: 'move',
      help: 'help',
      'not-allowed': 'not-allowed',
      none: 'none',
      'context-menu': 'context-menu',
      progress: 'progress',
      cell: 'cell',
      crosshair: 'crosshair',
      'vertical-text': 'vertical-text',
      alias: 'alias',
      copy: 'copy',
      'no-drop': 'no-drop',
      grab: 'grab',
      grabbing: 'grabbing',
      'all-scroll': 'all-scroll',
      'col-resize': 'col-resize',
      'row-resize': 'row-resize',
      'n-resize': 'n-resize',
      'e-resize': 'e-resize',
      's-resize': 's-resize',
      'w-resize': 'w-resize',
      'ne-resize': 'ne-resize',
      'nw-resize': 'nw-resize',
      'se-resize': 'se-resize',
      'sw-resize': 'sw-resize',
      'ew-resize': 'ew-resize',
      'ns-resize': 'ns-resize',
      'nesw-resize': 'nesw-resize',
      'nwse-resize': 'nwse-resize',
      'zoom-in': 'zoom-in',
      'zoom-out': 'zoom-out',
    },
    divideColor: ({ theme }) => theme('borderColor'),
    divideOpacity: ({ theme }) => theme('borderOpacity'),
    divideWidth: ({ theme }) => theme('borderWidth'),
    dropShadow: {
      sm: '0 1px 1px rgb(0 0 0 / 0.05)',
      DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'],
      md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'],
      lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'],
      xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'],
      '2xl': '0 25px 25px rgb(0 0 0 / 0.15)',
      none: '0 0 #0000',
    },
    fill: ({ theme }) => theme('colors'),
    grayscale: {
      0: '0',
      DEFAULT: '100%',
    },
    hueRotate: {
      0: '0deg',
      15: '15deg',
      30: '30deg',
      60: '60deg',
      90: '90deg',
      180: '180deg',
    },
    invert: {
      0: '0',
      DEFAULT: '100%',
    },
    flex: {
      1: '1 1 0%',
      auto: '1 1 auto',
      initial: '0 1 auto',
      none: 'none',
    },
    flexBasis: ({ theme }) => ({
      auto: 'auto',
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      '1/5': '20%',
      '2/5': '40%',
      '3/5': '60%',
      '4/5': '80%',
      '1/6': '16.666667%',
      '2/6': '33.333333%',
      '3/6': '50%',
      '4/6': '66.666667%',
      '5/6': '83.333333%',
      '1/12': '8.333333%',
      '2/12': '16.666667%',
      '3/12': '25%',
      '4/12': '33.333333%',
      '5/12': '41.666667%',
      '6/12': '50%',
      '7/12': '58.333333%',
      '8/12': '66.666667%',
      '9/12': '75%',
      '10/12': '83.333333%',
      '11/12': '91.666667%',
      full: '100%',
    }),
    flexGrow: {
      0: '0',
      DEFAULT: '1',
    },
    flexShrink: {
      0: '0',
      DEFAULT: '1',
    },
    fontFamily: {
      sans: [
        'ui-sans-serif',
        'system-ui',
        '-apple-system',
        'BlinkMacSystemFont',
        '"Segoe UI"',
        'Roboto',
        '"Helvetica Neue"',
        'Arial',
        '"Noto Sans"',
        'sans-serif',
        '"Apple Color Emoji"',
        '"Segoe UI Emoji"',
        '"Segoe UI Symbol"',
        '"Noto Color Emoji"',
      ],
      serif: [
        'ui-serif',
        'Georgia',
        'Cambria',
        '"Times New Roman"',
        'Times',
        'serif',
      ],
      mono: [
        'ui-monospace',
        'SFMono-Regular',
        'Menlo',
        'Monaco',
        'Consolas',
        '"Liberation Mono"',
        '"Courier New"',
        'monospace',
      ],
    },
    fontSize: {
      xs: ['0.75rem', { lineHeight: '1rem' }],
      sm: ['0.875rem', { lineHeight: '1.25rem' }],
      base: ['1rem', { lineHeight: '1.5rem' }],
      lg: ['1.125rem', { lineHeight: '1.75rem' }],
      xl: ['1.25rem', { lineHeight: '1.75rem' }],
      '2xl': ['1.5rem', { lineHeight: '2rem' }],
      '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
      '4xl': ['2.25rem', { lineHeight: '2.5rem' }],
      '5xl': ['3rem', { lineHeight: '1' }],
      '6xl': ['3.75rem', { lineHeight: '1' }],
      '7xl': ['4.5rem', { lineHeight: '1' }],
      '8xl': ['6rem', { lineHeight: '1' }],
      '9xl': ['8rem', { lineHeight: '1' }],
    },
    fontWeight: {
      thin: '100',
      extralight: '200',
      light: '300',
      normal: '400',
      medium: '500',
      semibold: '600',
      bold: '700',
      extrabold: '800',
      black: '900',
    },
    gap: ({ theme }) => theme('spacing'),
    gradientColorStops: ({ theme }) => theme('colors'),
    gridAutoColumns: {
      auto: 'auto',
      min: 'min-content',
      max: 'max-content',
      fr: 'minmax(0, 1fr)',
    },
    gridAutoRows: {
      auto: 'auto',
      min: 'min-content',
      max: 'max-content',
      fr: 'minmax(0, 1fr)',
    },
    gridColumn: {
      auto: 'auto',
      'span-1': 'span 1 / span 1',
      'span-2': 'span 2 / span 2',
      'span-3': 'span 3 / span 3',
      'span-4': 'span 4 / span 4',
      'span-5': 'span 5 / span 5',
      'span-6': 'span 6 / span 6',
      'span-7': 'span 7 / span 7',
      'span-8': 'span 8 / span 8',
      'span-9': 'span 9 / span 9',
      'span-10': 'span 10 / span 10',
      'span-11': 'span 11 / span 11',
      'span-12': 'span 12 / span 12',
      'span-full': '1 / -1',
    },
    gridColumnEnd: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
      13: '13',
    },
    gridColumnStart: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
      13: '13',
    },
    gridRow: {
      auto: 'auto',
      'span-1': 'span 1 / span 1',
      'span-2': 'span 2 / span 2',
      'span-3': 'span 3 / span 3',
      'span-4': 'span 4 / span 4',
      'span-5': 'span 5 / span 5',
      'span-6': 'span 6 / span 6',
      'span-full': '1 / -1',
    },
    gridRowStart: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
    },
    gridRowEnd: {
      auto: 'auto',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
    },
    gridTemplateColumns: {
      none: 'none',
      1: 'repeat(1, minmax(0, 1fr))',
      2: 'repeat(2, minmax(0, 1fr))',
      3: 'repeat(3, minmax(0, 1fr))',
      4: 'repeat(4, minmax(0, 1fr))',
      5: 'repeat(5, minmax(0, 1fr))',
      6: 'repeat(6, minmax(0, 1fr))',
      7: 'repeat(7, minmax(0, 1fr))',
      8: 'repeat(8, minmax(0, 1fr))',
      9: 'repeat(9, minmax(0, 1fr))',
      10: 'repeat(10, minmax(0, 1fr))',
      11: 'repeat(11, minmax(0, 1fr))',
      12: 'repeat(12, minmax(0, 1fr))',
    },
    gridTemplateRows: {
      none: 'none',
      1: 'repeat(1, minmax(0, 1fr))',
      2: 'repeat(2, minmax(0, 1fr))',
      3: 'repeat(3, minmax(0, 1fr))',
      4: 'repeat(4, minmax(0, 1fr))',
      5: 'repeat(5, minmax(0, 1fr))',
      6: 'repeat(6, minmax(0, 1fr))',
    },
    height: ({ theme }) => ({
      auto: 'auto',
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      '1/5': '20%',
      '2/5': '40%',
      '3/5': '60%',
      '4/5': '80%',
      '1/6': '16.666667%',
      '2/6': '33.333333%',
      '3/6': '50%',
      '4/6': '66.666667%',
      '5/6': '83.333333%',
      full: '100%',
      screen: '100vh',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
    }),
    inset: ({ theme }) => ({
      auto: 'auto',
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      full: '100%',
    }),
    keyframes: {
      spin: {
        to: {
          transform: 'rotate(360deg)',
        },
      },
      ping: {
        '75%, 100%': {
          transform: 'scale(2)',
          opacity: '0',
        },
      },
      pulse: {
        '50%': {
          opacity: '.5',
        },
      },
      bounce: {
        '0%, 100%': {
          transform: 'translateY(-25%)',
          animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
        },
        '50%': {
          transform: 'none',
          animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
        },
      },
      fadeIn: {
        from: {
          opacity: 0,
        },
        to: {
          opacity: 1,
        },
      },
    },
    letterSpacing: {
      tighter: '-0.05em',
      tight: '-0.025em',
      normal: '0em',
      wide: '0.025em',
      wider: '0.05em',
      widest: '0.1em',
    },
    lineHeight: {
      none: '1',
      tight: '1.25',
      snug: '1.375',
      normal: '1.5',
      relaxed: '1.625',
      loose: '2',
      3: '.75rem',
      4: '1rem',
      5: '1.25rem',
      6: '1.5rem',
      7: '1.75rem',
      8: '2rem',
      9: '2.25rem',
      10: '2.5rem',
    },
    listStyleType: {
      none: 'none',
      disc: 'disc',
      decimal: 'decimal',
    },
    margin: ({ theme }) => ({
      auto: 'auto',
      ...theme('spacing'),
    }),
    maxHeight: ({ theme }) => ({
      ...theme('spacing'),
      full: '100%',
      screen: '100vh',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
    }),
    maxWidth: ({ theme, breakpoints }) => ({
      none: 'none',
      0: '0rem',
      xs: '20rem',
      sm: '24rem',
      md: '28rem',
      lg: '32rem',
      xl: '36rem',
      '2xl': '42rem',
      '3xl': '48rem',
      '4xl': '56rem',
      '5xl': '64rem',
      '6xl': '72rem',
      '7xl': '80rem',
      full: '100%',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
      prose: '65ch',
      ...breakpoints(theme('screens')),
    }),
    minHeight: {
      0: '0px',
      full: '100%',
      screen: '100vh',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
    },
    minWidth: {
      0: '0px',
      full: '100%',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
    },
    objectPosition: {
      bottom: 'bottom',
      center: 'center',
      left: 'left',
      'left-bottom': 'left bottom',
      'left-top': 'left top',
      right: 'right',
      'right-bottom': 'right bottom',
      'right-top': 'right top',
      top: 'top',
    },
    opacity: {
      0: '0',
      5: '0.05',
      10: '0.1',
      20: '0.2',
      25: '0.25',
      30: '0.3',
      40: '0.4',
      50: '0.5',
      60: '0.6',
      70: '0.7',
      75: '0.75',
      80: '0.8',
      90: '0.9',
      95: '0.95',
      100: '1',
    },
    order: {
      first: '-9999',
      last: '9999',
      none: '0',
      1: '1',
      2: '2',
      3: '3',
      4: '4',
      5: '5',
      6: '6',
      7: '7',
      8: '8',
      9: '9',
      10: '10',
      11: '11',
      12: '12',
    },
    padding: ({ theme }) => theme('spacing'),
    placeholderColor: ({ theme }) => theme('colors'),
    placeholderOpacity: ({ theme }) => theme('opacity'),
    outlineColor: ({ theme }) => theme('colors'),
    outlineOffset: {
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    outlineWidth: {
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    ringColor: ({ theme }) => ({
      DEFAULT: theme('colors.blue.500', '#3b82f6'),
      ...theme('colors'),
    }),
    ringOffsetColor: ({ theme }) => theme('colors'),
    ringOffsetWidth: {
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    ringOpacity: ({ theme }) => ({
      DEFAULT: '0.5',
      ...theme('opacity'),
    }),
    ringWidth: {
      DEFAULT: '3px',
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    rotate: {
      0: '0deg',
      1: '1deg',
      2: '2deg',
      3: '3deg',
      6: '6deg',
      12: '12deg',
      45: '45deg',
      90: '90deg',
      180: '180deg',
    },
    saturate: {
      0: '0',
      50: '.5',
      100: '1',
      150: '1.5',
      200: '2',
    },
    scale: {
      0: '0',
      50: '.5',
      75: '.75',
      90: '.9',
      95: '.95',
      100: '1',
      105: '1.05',
      110: '1.1',
      125: '1.25',
      150: '1.5',
    },
    scrollMargin: ({ theme }) => ({
      ...theme('spacing'),
    }),
    scrollPadding: ({ theme }) => theme('spacing'),
    sepia: {
      0: '0',
      DEFAULT: '100%',
    },
    skew: {
      0: '0deg',
      1: '1deg',
      2: '2deg',
      3: '3deg',
      6: '6deg',
      12: '12deg',
    },
    space: ({ theme }) => ({
      ...theme('spacing'),
    }),
    stroke: ({ theme }) => theme('colors'),
    strokeWidth: {
      0: '0',
      1: '1',
      2: '2',
    },
    textColor: ({ theme }) => theme('colors'),
    textDecorationColor: ({ theme }) => theme('colors'),
    textDecorationThickness: {
      auto: 'auto',
      'from-font': 'from-font',
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    textUnderlineOffset: {
      auto: 'auto',
      0: '0px',
      1: '1px',
      2: '2px',
      4: '4px',
      8: '8px',
    },
    textIndent: ({ theme }) => ({
      ...theme('spacing'),
    }),
    textOpacity: ({ theme }) => theme('opacity'),
    transformOrigin: {
      center: 'center',
      top: 'top',
      'top-right': 'top right',
      right: 'right',
      'bottom-right': 'bottom right',
      bottom: 'bottom',
      'bottom-left': 'bottom left',
      left: 'left',
      'top-left': 'top left',
    },
    transitionDelay: {
      75: '75ms',
      100: '100ms',
      150: '150ms',
      200: '200ms',
      300: '300ms',
      500: '500ms',
      700: '700ms',
      1000: '1000ms',
    },
    transitionDuration: {
      DEFAULT: '150ms',
      75: '75ms',
      100: '100ms',
      150: '150ms',
      200: '200ms',
      300: '300ms',
      500: '500ms',
      700: '700ms',
      1000: '1000ms',
    },
    transitionProperty: {
      none: 'none',
      all: 'all',
      DEFAULT:
        'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
      colors: 'background-color, border-color, color, fill, stroke',
      opacity: 'opacity',
      shadow: 'box-shadow',
      transform: 'transform',
    },
    transitionTimingFunction: {
      DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)',
      linear: 'linear',
      in: 'cubic-bezier(0.4, 0, 1, 1)',
      out: 'cubic-bezier(0, 0, 0.2, 1)',
      'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
    },
    translate: ({ theme }) => ({
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      full: '100%',
    }),
    width: ({ theme }) => ({
      auto: 'auto',
      ...theme('spacing'),
      '1/2': '50%',
      '1/3': '33.333333%',
      '2/3': '66.666667%',
      '1/4': '25%',
      '2/4': '50%',
      '3/4': '75%',
      '1/5': '20%',
      '2/5': '40%',
      '3/5': '60%',
      '4/5': '80%',
      '1/6': '16.666667%',
      '2/6': '33.333333%',
      '3/6': '50%',
      '4/6': '66.666667%',
      '5/6': '83.333333%',
      '1/12': '8.333333%',
      '2/12': '16.666667%',
      '3/12': '25%',
      '4/12': '33.333333%',
      '5/12': '41.666667%',
      '6/12': '50%',
      '7/12': '58.333333%',
      '8/12': '66.666667%',
      '9/12': '75%',
      '10/12': '83.333333%',
      '11/12': '91.666667%',
      full: '100%',
      screen: '100vw',
      min: 'min-content',
      max: 'max-content',
      fit: 'fit-content',
    }),
    willChange: {
      auto: 'auto',
      scroll: 'scroll-position',
      contents: 'contents',
      transform: 'transform',
    },
    zIndex: {
      auto: 'auto',
      0: '0',
      10: '10',
      20: '20',
      30: '30',
      40: '40',
      50: '50',
    },
  },
  variantOrder: [
    'first',
    'last',
    'odd',
    'even',
    'visited',
    'checked',
    'empty',
    'read-only',
    'group-hover',
    'group-focus',
    'focus-within',
    'hover',
    'focus',
    'focus-visible',
    'active',
    'disabled',
  ],
}

Did you find this article valuable?

Support Rory by becoming a sponsor. Any amount is appreciated!