Step 12 of 21 (57% complete)

Code Cleanup and Enhancement Guide

This guide covers four improvements to your Next.js and Optimizely SaaS CMS integration:

1. Fixing ESLint Errors

To prevent ESLint errors with generated types:

  1. Add the generated types file to .prettierignore:
lib/optimizely/types/generated.ts
  1. Add this to your eslint.config.mjs:
{
  files: ["lib/optimizely/types/generated.ts"],
  rules: {
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-empty-object-type": "off",
  },
}
  1. For the block factory mapper, add this comment:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Note
This isn't the best solution, but it works for now. If you know a better way, please open a PR!

2. Using Auto-Generated Types in Blocks

To achieve full integration with Optimizely Graph, we'll change the types to those generated from Codegen CLI. This ensures that our blocks are using the most up-to-date and accurate types.

image

Replace hardcoded types with those generated from Codegen CLI:

  1. Remove hardcoded types from v0
  2. Import types from the generated file
  3. Use as when type names conflict with component names:
import { ContactBlock as ContactBlockProps } from '@/lib/optimizely/types/generated'

Handling Nested Blocks

Since Optimizely returns all possible block types (even when only one is allowed), create this helper:

// lib/optimizely/types/typeUtils.ts

import type { _IContent } from '@/lib/optimizely/types/generated'

export type SafeContent = {
  __typename?: string
} & _IContent

// Utility type to extract a specific type from _IContent union
export type ExtractContent<T extends { __typename: string }> = Extract<
  _IContent,
  { __typename?: T['__typename'] }
>

// Helper function to safely cast _IContent to a specific type
export function castContent<T extends { __typename?: string }>(
  content: SafeContent | null | undefined,
  typename: T['__typename']
): T | null {
  if (content && content?.__typename === typename) {
    return content as unknown as T
  }
  return null
}

Use it in components like this:

// components/layout/header.tsx

export async function Header({ locale }: { locale: string }) {
  // ... (previous code)

  return (
    <header className="sticky top-0 z-30 border-b bg-white">
      {/* ... (other header content) */}
      <nav className="hidden items-center gap-6 md:flex">
        {navItems?.map((navItem) => {
          const item = castContent<NavItem>(
            navItem as SafeContent,
            'NavItem'
          )
          if (!item) return null

          return (
            <Link
              key={item.href}
              href={item?.href ?? '/'}
              className="text-sm font-medium"
            >
              {item.label}
            </Link>
          )
        })}
      </nav>
      {/* ... (rest of the header) */}
    </header>
  )
}

3. Adding SEO Metadata

Use Next.js's generateMetadata function:

  1. Create a helper for generating alternate URLs based on path and locale:
// lib/utils/metadata.ts
import { LOCALES } from '@/lib/optimizely/utils/language'
import { AlternateURLs } from 'next/dist/lib/metadata/types/alternative-urls-types'

export function normalizePath(path: string): string {
  path = path.toLowerCase()

  if (path === '/') {
    return ''
  }

  if (path.endsWith('/')) {
    path = path.substring(0, path.length - 1)
  }

  if (path.startsWith('/')) {
    path = path.substring(1)
  }

  return path
}

export function generateAlternates(
  locale: string,
  path: string
): AlternateURLs {
  path = normalizePath(path)

  return {
    canonical: `/${locale}/${path}`,
    languages: Object.assign(
      {},
      ...LOCALES.map((l) => ({ [l]: `/${l}/${path}` }))
    ),
  }
}
  1. Add metadata to your pages:
//app/[locale]/page.tsx
import { generateAlternates } from '@/lib/utils/metadata'
import { Metadata } from 'next'

export async function generateMetadata(props: {
  params: Promise<{ locale: string }>
}): Promise<Metadata> {
  const { locale } = await props.params
  const locales = getValidLocale(locale)
  const pageResp = await optimizely.GetStartPage({ locales })
  const page = pageResp.data?.StartPage?.items?.[0]
  if (!page) {
    return {}
  }

  return {
    title: page.title,
    description: page.shortDescription || '',
    keywords: page.keywords ?? '',
    alternates: generateAlternates(locale, '/'),
  }
}
//app/[locale]/[slug]/page.tsx
import { generateAlternates } from '@/lib/utils/metadata'
import { Metadata } from 'next'

export async function generateMetadata(props: {
  params: Promise<{ locale: string; slug?: string }>
}): Promise<Metadata> {
  const { locale, slug = '' } = await props.params
  const locales = getValidLocale(locale)
  const { data, errors } = await optimizely.getPageByURL({
    locales: [locales],
    slug: `/${slug}`,
  })

  if (errors || !data?.CMSPage?.items?.[0]) {
    return {}
  }

  const page = data.CMSPage.items[0]
  if (!page) {
    return {}
  }

  return {
    title: page.title,
    description: page.shortDescription || '',
    keywords: page.keywords ?? '',
    alternates: generateAlternates(locale, '/'),
  }
}
Note
I use the same methods as to retrieve information about the whole page and all blocks, if we wanted to optimize this we should create a new graphql query that will retrieve information only about SEO.

4. Adding Header, Footer, and Not Found Page

  1. Add a Not Found page:
// app/[locale]/not-found.tsx
import { Button } from '@/components/ui/button'
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-background px-3 text-foreground">
      <h1 className="mb-4 text-4xl font-bold">404 - Page Not Found</h1>
      <p className="mb-8 text-xl text-muted-foreground">
        Oops! The page you are looking for does not exist.
      </p>
      <Button asChild>
        <Link href="/">Go back home</Link>
      </Button>
    </div>
  )
}
  1. Add Footer
// components/layout/footer.tsx
import Link from 'next/link'
import { Icons } from '@/components/ui/icons'
import { getValidLocale } from '@/lib/optimizely/utils/language'
import { optimizely } from '@/lib/optimizely/fetch'
import { castContent, SafeContent } from '@/lib/optimizely/types/typeUtils'
import {
  SocialLink,
  FooterColumn,
  NavItem,
} from '@/lib/optimizely/types/generated'

export async function Footer({ locale }: { locale: string }) {
  const locales = getValidLocale(locale)
  const { data } = await optimizely.getFooter(
    { locales: locales },
    { cacheTag: 'optimizely-footer' }
  )
  const footer = data?.Footer?.items?.[0]
  if (!footer) {
    return null
  }

  const { columns, socialLinks, copyrightText } = footer

  return (
    <footer className="border-t">
      <div className="container mx-auto px-4 py-12">
        <div className="grid gap-8 md:grid-cols-4">
          {columns?.map((columnItem, index) => {
            const column = castContent<FooterColumn>(
              columnItem as SafeContent,
              'FooterColumn'
            )
            if (!column) return null

            return (
              <div key={index}>
                <h3 className="mb-4 font-bold">{column?.title}</h3>
                <nav className="grid gap-2">
                  {column?.links?.map((linkItem, linkIndex) => {
                    const link = castContent<NavItem>(linkItem, 'NavItem')
                    if (!link) return null

                    return (
                      <Link
                        key={linkIndex}
                        href={link.href ?? '/'}
                        className="text-sm"
                      >
                        {link.label}
                      </Link>
                    )
                  })}
                </nav>
              </div>
            )
          })}
        </div>
        <div className="mt-8 flex justify-center gap-4">
          {socialLinks?.map((linkItem, index) => {
            const link = castContent<SocialLink>(
              linkItem as SafeContent,
              'SocialLink'
            )
            if (!link) return null
            const platform = (link?.platform ?? '') as keyof typeof Icons

            const Icon = platform ? Icons?.[platform] : null
            return (
              <Link
                key={index}
                href={link?.href ?? '/'}
                className="text-muted-foreground hover:text-foreground"
              >
                {Icon && <Icon className="h-5 w-5" />}
              </Link>
            )
          })}
        </div>
        <div className="mt-8 border-t pt-8 text-center text-sm text-muted-foreground">
          {copyrightText}
        </div>
      </div>
    </footer>
  )
}
  1. Add header
// components/layout/header.tsx
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { optimizely } from '@/lib/optimizely/fetch'
import { getValidLocale } from '@/lib/optimizely/utils/language'
import { castContent, SafeContent } from '@/lib/optimizely/types/typeUtils'
import { NavItem } from '@/lib/optimizely/types/generated'
import Image from 'next/image'
import { LanguageSwitcher } from './language-switcher'

export async function Header({ locale }: { locale: string }) {
  const locales = getValidLocale(locale)
  const { data } = await optimizely.getHeader(
    { locale: locales },
    { cacheTag: 'optimizely-header' }
  )
  const header = data?.Header?.items?.[0]
  if (!header) {
    return null
  }

  const { logo, ctaHref, ctaText, navItems } = header

  return (
    <header className="sticky top-0 z-30 border-b bg-white">
      <div className="container mx-auto px-4">
        <div className="flex h-16 items-center justify-between">
          <Link href="/" className="text-xl font-bold lg:min-w-[150px]">
            <Image src={logo ?? ''} width={50} height={50} alt="logo" />
          </Link>
          <nav className="hidden items-center gap-6 md:flex">
            {navItems?.map((navItem) => {
              const item = castContent<NavItem>(
                navItem as SafeContent,
                'NavItem'
              )
              if (!item) return null

              return (
                <Link
                  key={item.href}
                  href={item?.href ?? '/'}
                  className="text-sm font-medium"
                >
                  {item.label}
                </Link>
              )
            })}
          </nav>
          <div className="flex items-center gap-4">
            <LanguageSwitcher currentLocale={locale} />
            <Button variant="outline" asChild>
              <Link href={ctaHref ?? '/'}>{ctaText}</Link>
            </Button>
          </div>
        </div>
      </div>
    </header>
  )
}
// components/layout/language-switcher.tsx
'use client'

import { usePathname, useRouter } from 'next/navigation'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Globe } from 'lucide-react'
import { LOCALES } from '@/lib/optimizely/utils/language'
import { Button } from '../ui/button'

const LOCALE_NAMES: Record<string, string> = {
  en: 'English',
  pl: 'Polski',
  sv: 'Svenska',
}

export function LanguageSwitcher({ currentLocale }: { currentLocale: string }) {
  const pathname = usePathname()
  const router = useRouter()

  const handleLocaleChange = (newLocale: string) => {
    const currentPath = pathname

    const newPath = currentPath.includes(`/${currentLocale}`)
      ? currentPath.replace(`/${currentLocale}`, `/${newLocale}`)
      : `/${newLocale}/${currentPath}`

    router.push(newPath)
    router.refresh()
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Globe className="h-4 w-4" />
          <span className="sr-only">Switch language</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {LOCALES.map((loc) => (
          <DropdownMenuItem
            key={loc}
            className={loc === currentLocale ? 'bg-accent' : ''}
            onClick={() => handleLocaleChange(loc)}
          >
            {LOCALE_NAMES[loc]}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
Note
it is very important to add router.refresh() because without this all next/Link components will have in cache the previously selected language
  1. Add header and footer components to your layout.tsx file
// app/[locale]/layout.tsx

export default async function RootLayout({
  children,
  params,
}: Readonly<{
  children: React.ReactNode
  params: Promise<{ locale: string }>
}>) {
  const { locale } = await params
  return (
    <html lang={locale}>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <Header locale={locale} />
        <main className="container mx-auto min-h-screen px-4">{children}</main>
        <Footer locale={locale} />
      </body>
    </html>
  )
}

By following these steps, you'll improve your code quality, type safety, and SEO to your Next.js project with Optimizely CMS.

Have questions? I'm here to help!

Contact Me