Loading...
Next.js 14 - typescript contentful tailwindcss - full example
Author Cloudapp
E.G.

Next.js 14 - Complete Example - Typescript / Tailwindcss / Contentful - Part 2

March 11, 2024
Table of Contents

Let's follow up on our project which we created in the previous post Nextjs 14 - Complete Example with Typescript - Contentful - App Router

Review Contentful Content-Types

Here is the content from the seoFields.graphql file (folder -> src/lib/graphql)

fragment SeoFields on ComponentSeo {
  __typename
  pageTitle
  pageDescription
  canonicalUrl
  follow
  index
  shareImagesCollection(limit: 3, locale: $locale) {
    items {
      ...ImageFields
    }
  }
}

this screenshot shows the content fields from the content type seoFields within Contentful

Contentful seoFields Content Type
Contentful seoFields Content Type

Here is an overview of all content types that we currently have on Contentful

Contentful overview content types
Contentful overview content types

Let's improve the UI of our blog.

First of all, we install/update these npm packages:

Installation needed NPM packages

npm install @contentful/f36-tokens @tailwindcss/typography tailwindcss@latest

f36-tokens is one part of the F36 Design System from Contentful. Now let's modify the main Tailwindcss config file: tailwind.config.ts in the project root

Before

##old tailwind.config.ts##
import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      backgroundImage: {
        'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
        'gradient-conic':
          'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
      },
    },
  },
  plugins: [],
}
export default config

Changes on tailwind.config.ts

After

##new tailwind.config.ts##
import type { Config } from "tailwindcss";
import tokens from "@contentful/f36-tokens";
const { fontFamily } = require("tailwindcss/defaultTheme");

const colors = Object.entries(tokens).reduce(
  (acc: Record<string, any>, [key, value]) => {
    // Filter Hex colors from the f36-tokens
    if (/^#[0-9A-F]{6}$/i.test(value as any)) {
      acc[key] = value;
    }
    return acc;
  },
  {} as Record<string, string>
);
const config: Config = {
  darkMode: "class",
  content: [
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    colors,
    extend: {
      maxWidth: {
        "8xl": "90rem",
      },
      letterSpacing: {
        snug: "-0.011em",
      },
      boxShadow: {
        // light
        "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
        "tremor-card":
          "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
        "tremor-dropdown":
          "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
      },
      borderRadius: {
        "tremor-small": "0.375rem",
        "tremor-default": "0.5rem",
        "tremor-full": "9999px",
      },
      fontSize: {
        "2xs": "0.625rem",
        "3xl": "1.75rem",
        "4xl": "2.5rem",
        "tremor-label": "0.75rem",
        "tremor-default": ["1.0rem", { lineHeight: "1.25rem" }],
        "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
        "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
      },
      lineHeight: {
        tighter: "1.1",
      },
      fontFamily: {
        sans: ["var(--font-urbanist)", ...fontFamily.sans],
      },
    },
  },
  plugins: [require("@tailwindcss/typography")],
};
export default config;

First, we import:

import tokens from "@contentful/f36-tokens";
const { fontFamily } = require("tailwindcss/defaultTheme");

then we loop through the tokens (Filtering only the Hex-Colors) and use the new "colors" in the tailwind.config.ts file -> "theme" section.

const colors = Object.entries(tokens).reduce((acc: Record<string, any>, [key, value]) => { 
// Filter Hex colors from the f36-tokens
    if (/^#[0-9A-F]{6}$/i.test(value as any)) {
      acc[key] = value;
    }
    return acc;
  },  {} as Record<string, string>);

Changes on global.css

As a next step, we modify the file global.css under src/app. We add a few styles as base layers.

By using the @layer directive, Tailwind will automatically move those styles to the same place as @tailwind base to avoid unintended specificity issues.

Using the @layer directive will also instruct Tailwind to consider those styles for purging when purging the base layer.

We use @apply to define these styles to avoid introducing new magic values or accidentally deviating from your design system.

Here is a good explanation regarding base layers from tailwindcss.com https://v1.tailwindcss.com/docs/adding-base-styles


@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    @apply text-sm dark:text-gray400 text-gray800 md:text-sm;
  }
  .h1,
  h1 {
    @apply text-xl font-semibold dark:text-[#FAFAFA] text-gray600 leading-tighter md:text-4xl;
  }
  .h2,
  h2 {
    @apply text-xl font-semibold leading-tight dark:text-[#FAFAFA] text-gray600 md:text-3xl;
  }
  .h3,
  h3 {
    @apply text-base font-semibold leading-relaxed dark:text-[#FAFAFA] text-gray600 md:text-xl;
  }
  .h4,
  h4 {
    @apply text-xs font-semibold leading-normal dark:text-[#FAFAFA] text-gray600 md:text-base;
  }
  p {
    @apply text-xl leading-normal dark:text-[#FAFAFA] text-gray600 tracking-snug md:leading-normal;
  }
}
The file global.css is used in our layout.tsx file


##src/app/layout.tsx##
import './globals.css'

We are ready now for the new components that we need to add and the changes on the page.tsx. In total we will add 6 components:

Vscode new components and changed page.tsx
Vscode new components and changed page.tsx

New components and changes on pages

In the file src/app/page.tsx we added these code lines: New Imports New Imports
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";

After adding the new imports we have there should be these lines

import { ArticleContent } from "@/components/contentful/ArticleContent.component";
import { client } from "@/lib/client";
import { notFound } from "next/navigation";
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";

Let’s add a new folder [slug] under src/app. If a folder’s name is wrapped in square brackets, we create a “Dynamic Segment”.

Example

For example, a blog could include the following route app/blog/[slug]/page.tsx where [slug] is the Dynamic Segment for blog posts.

export default function Page({ params }: { params: { slug: string } }) {
  return <div>My Post: {params.slug}</div>
}
Dynamic Segments
Dynamic Segments

in the new folder [slug] we create a new page.tsx file, where we use our new components as well.

import { ArticleContent } from "@/components/contentful/ArticleContent.component";
import { client } from "@/lib/client";
import { notFound } from "next/navigation";
import { ArticleHero } from "@/components/contentful/ArticleHero";
import { ArticleTileGrid } from "@/components/contentful/ArticleTileGrid";
import { Container } from "@/components/contentful/container/Container";

interface BlogPostPageParams {
  slug: string;
  locale: string;
}

interface BlogPostPageProps {
  params: BlogPostPageParams;
}

const locales = ["de-DE"]; //Fake locales for the purpose of the example
// Tell Next.js about all our blog posts so
// they can be statically generated at build time.
export async function generateStaticParams(): Promise<BlogPostPageParams[]> {
  const dataPerLocale = locales
    ? await Promise.all(
        locales.map((locale) => client.pageBlogPostCollection({ limit: 100 }))
      )
    : [];

  const paths = dataPerLocale
    .flatMap((data, index) =>
      data.pageBlogPostCollection?.items.map((blogPost) =>
        blogPost?.slug
          ? {
              slug: blogPost.slug,
              locale: locales?.[index] || "",
            }
          : undefined
      )
    )
    .filter(Boolean);

  return paths as BlogPostPageParams[];
}

async function BlogPostPage({ params }: BlogPostPageProps) {
  const [blogPagedata] = await Promise.all([
    client.pageBlogPost({
      slug: params.slug.toString(),
    }),
  ]);

  const blogPost = blogPagedata.pageBlogPostCollection?.items[0];

  if (!blogPost) {
    // If a blog post can't be found,
    // tell Next.js to render a 404 page.
    return notFound();
  }

  const relatedPosts = blogPost?.relatedBlogPostsCollection?.items;

  if (!blogPost || !relatedPosts) return null;

  return (
    <>
      <div className="mt-4" />
      <Container>
        <ArticleHero
          article={blogPost}
          isReversedLayout={true}
          isHomePage={false}
        />
      </Container>
      <Container className="max-w-4xl mt-8">
        <ArticleContent article={blogPost} />
      </Container>
      {relatedPosts.length > 0 && (
        <Container className="max-w-5xl mt-8">
          <h2 className="mb-4 md:mb-6">Related Posts</h2>
          <ArticleTileGrid className="md:grid-cols-2" articles={relatedPosts} />
        </Container>
      )}
    </>
  );
}

export default BlogPostPage;

under the Imports, we create our interfaces

Interface is a structure that defines the contract in your application. It defines the syntax for classes to follow. Classes that are derived from an interface must follow the structure provided by their interface.

An interface is defined with the keyword interface and it can include properties and method declarations using a function or an arrow function.

interface BlogPostPageParams {
  slug: string;
  locale: string;
}

interface BlogPostPageProps {
  params: BlogPostPageParams;
}

The generateStaticParams function can be used in combination with dynamic route segments to statically generate routes at build time instead of on-demand at request time.

The primary benefit of the generateStaticParams function is its smart retrieval of data. If content is fetched within the generateStaticParams function using a fetch request, the requests are automatically memoized. This means a fetch request with the same arguments across multiple generateStaticParams, Layouts, and Pages will only be made once, which decreases build times.

export async function generateStaticParams(): Promise<BlogPostPageParams[]> {
  const dataPerLocale = locales
    ? await Promise.all(
        locales.map((locale) => client.pageBlogPostCollection({ limit: 100 }))
      )
    : [];

  const paths = dataPerLocale
    .flatMap((data, index) =>
      data.pageBlogPostCollection?.items.map((blogPost) =>
        blogPost?.slug
          ? {
              slug: blogPost.slug,
              locale: locales?.[index] || "",
            }
          : undefined
      )
    )
    .filter(Boolean);

  return paths as BlogPostPageParams[];
}

Below is the new version of our blog after our applied changes. Start the dev server “npm run dev” and open “http://locahost:3000”

Testblog
Testblog

Now our blog works, we have a homepage, and if we click on the blog posts below we will land on the corresponding blog post page.

GitHub Repo

Stay tuned for the next posts where we will add new components like Navbar, Footer, etc. and we will also add an Azure Entra ID (formerly known as Azure AD B2C) for the identity management and Algolia as an On-Site Search Engine.

The full code is accessible from my GitHub Repo

Stay tuned and support me with a clap or follow me on medium.com.

Related articles