โ† Back to DevBytes

Server-Side Rendering with Chakra UI: SSR, SSG, ISR

Server-Side Rendering with Chakra UI: SSR, SSG, ISR

Chakra UI is one of the most popular React component libraries, prized for its accessibility, theming, and developer experience. But when you bring it into a server-rendered Next.js application, you need to handle a few specific concerns: the custom cache for Emotion (Chakra's underlying CSS-in-JS engine), color mode persistence, and hydration mismatches. This tutorial walks you through everything you need to render Chakra UI correctly across SSR, SSG, and ISR rendering strategies in Next.js.

Why Server Rendering Matters with Chakra UI

Chakra UI uses Emotion under the hood to generate styles. In a client-only React app, styles are injected into the DOM as components mount. In a server-rendered app, however, the server must produce the final HTML and the critical CSS so the browser can paint immediately without waiting for JavaScript. If you skip the server-side style extraction, users will see a flash of unstyled content (FOUC) and may experience a hydration mismatch warning in the console.

The three rendering strategies we'll cover each serve different use cases:

Project Setup

Start by creating a new Next.js app and installing Chakra UI along with its peer dependencies. These instructions assume Next.js 13+ with the Pages Router, which is still the most common pattern for Chakra UI SSR setups.

npx create-next-app@latest chakra-ssr-demo
cd chakra-ssr-demo
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion

Next, create the Emotion cache that will be shared between server and client. This cache is what allows the server to extract critical CSS and inject it into the response.

// lib/chakra-cache.js
import createCache from '@emotion/cache';

export default function createEmotionCache() {
  return createCache({ key: 'css', prepend: true });
}

Building the Chakra Provider

The ChakraProvider must wrap your entire application, and it needs to receive the Emotion cache so styles are consistent between server and client. Create a custom provider component that accepts a pageProps object and reconstructs the cache on each render.

// components/Chakra.js
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { CacheProvider } from '@emotion/react';
import createEmotionCache from '../lib/chakra-cache';

const emotionCache = createEmotionCache();

const theme = extendTheme({
  config: {
    initialColorMode: 'light',
    useSystemColorMode: false,
  },
  fonts: {
    heading: 'Inter, sans-serif',
    body: 'Inter, sans-serif',
  },
});

export function Chakra({ children }) {
  return (
    <CacheProvider value={emotionCache}>
      <ChakraProvider theme={theme}>
        {children}
      </ChakraProvider>
    </CacheProvider>
  );
}

Wiring the Provider into _app.js

Every page in your Next.js app passes through _app.js. This is where you mount the Chakra provider. Because _app.js runs on both server and client, the cache is available in both contexts.

// pages/_app.js
import { Chakra } from '../components/Chakra';

export default function App({ Component, pageProps }) {
  return (
    <Chakra>
      <Component {...pageProps} />
    </Chakra>
  );
}

Handling the Document for SSR

The _document.js file is where the magic of server-side style extraction happens. You override getInitialProps to collect styles from the Emotion cache and inject them as <style> tags in the document head. Without this step, your SSR pages will ship without critical CSS.

// pages/_document.js
import { ColorModeScript } from '@chakra-ui/react';
import NextDocument, { Html, Head, Main, NextScript } from 'next/document';
import createEmotionCache from '../lib/chakra-cache';
import { extendTheme } from '@chakra-ui/react';

const theme = extendTheme({
  config: { initialColorMode: 'light', useSystemColorMode: false },
});

export default class Document extends NextDocument {
  static async getInitialProps(ctx) {
    const originalRenderPage = ctx.renderPage;
    const cache = createEmotionCache();
    const { extractCriticalToChunks } = await import('@emotion/server');

    ctx.renderPage = () =>
      originalRenderPage({
        enhanceApp: (App) => (props) => <App emotionCache={cache} {...props} />,
      });

    const initialProps = await NextDocument.getInitialProps(ctx);
    const emotionStyles = extractCriticalToChunks(initialProps.html);
    const emotionStyleTags = emotionStyles.styles.map((style) => (
      <style
        data-emotion={`${style.key} ${style.ids.join(' ')}`}
        key={style.key}
        dangerouslySetInnerHTML={{ __html: style.css }}
      />
    ));

    return {
      ...initialProps,
      styles: [
        ...React.Children.toArray(initialProps.styles),
        ...emotionStyleTags,
      ],
    };
  }

  render() {
    return (
      <Html lang="en">
        <Head />
        <body>
          <ColorModeScript initialColorMode={theme.config.initialColorMode} />
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Don't forget to import React at the top of _document.js since we use React.Children.toArray. The ColorModeScript is critical: it runs before hydration to read the user's saved color mode from localStorage and apply the correct class, preventing the dreaded flash of the wrong theme.

SSR: Server-Side Rendering with Chakra UI

With the foundation in place, SSR works automatically. Any page that exports getServerSideProps will be rendered on the server for each request, and the Emotion cache will extract the styles via _document.js. Here's an example page that fetches data on every request.

// pages/dashboard.js
import { Box, Heading, Text, Table, Thead, Tbody, Tr, Th, Td } from '@chakra-ui/react';

export default function Dashboard({ users, generatedAt }) {
  return (
    <Box p={8} maxW="container.lg" mx="auto">
      <Heading mb={4}>User Dashboard</Heading>
      <Text mb={6} color="gray.500">
        Generated at {generatedAt}
      </Text>
      <Table variant="simple">
        <Thead>
          <Tr>
            <Th>Name</Th>
            <Th>Email</Th>
            <Th>Role</Th>
          </Tr>
        </Thead>
        <Tbody>
          {users.map((user) => (
            <Tr key={user.id}>
              <Td>{user.name}</Td>
              <Td>{user.email}</Td>
              <Td>{user.role}</Td>
            </Tr>
          ))}
        </Tbody>
      </Table>
    </Box>
  );
}

export async function getServerSideProps() {
  const res = await fetch('https://api.example.com/users');
  const users = await res.json();

  return {
    props: {
      users,
      generatedAt: new Date().toISOString(),
    },
  };
}

Because getServerSideProps runs on every request, the rendered HTML always reflects the latest data. The styles are extracted server-side and sent inline, so the page paints correctly before JavaScript loads.

SSG: Static Site Generation with Chakra UI

SSG generates the HTML once at build time. This is ideal for content that doesn't change often. Chakra UI works seamlessly here because the build process runs the same Emotion extraction as SSR. Use getStaticProps and optionally getStaticPaths for dynamic routes.

// pages/blog/[slug].js
import { Box, Heading, Text, Tag, Stack, Divider } from '@chakra-ui/react';

export default function BlogPost({ post }) {
  return (
    <Box maxW="container.md" mx="auto" py={10} px={6}>
      <Tag colorScheme="teal" mb={3}>{post.category}</Tag>
      <Heading as="h1" size="2xl" mb={4}>
        {post.title}
      </Heading>
      <Text color="gray.500" mb={8}>
        By {post.author} ยท {post.readTime} min read
      </Text>
      <Divider mb={8} />
      <Stack spacing={4}>
        {post.content.split('\n\n').map((para, i) => (
          <Text key={i} fontSize="lg" lineHeight="tall">
            {para}
          </Text>
        ))}
      </Stack>
    </Box>
  );
}

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  const paths = posts.map((post) => ({
    params: { slug: post.slug },
  }));

  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/posts/${params.slug}`);
  const post = await res.json();

  return {
    props: { post },
  };
}

At build time, Next.js will pre-render every blog post page with full Chakra styling baked into the HTML. These pages can be served from a CDN for maximum performance.

ISR: Incremental Static Regeneration with Chakra UI

ISR gives you the performance of static pages with the freshness of server rendering. You add a revalidate property to getStaticProps, and Next.js will regenerate the page in the background after the specified number of seconds. Chakra UI requires no special configuration for ISR โ€” the same style extraction pipeline applies.

// pages/products/[id].js
import {
  Box,
  Heading,
  Text,
  Badge,
  Button,
  Stack,
  Image,
  Flex,
  NumberInput,
  NumberInputField,
  NumberInputStepper,
  NumberIncrementStepper,
  NumberDecrementStepper,
} from '@chakra-ui/react';

export default function ProductPage({ product }) {
  return (
    <Box maxW="container.xl" mx="auto" py={10} px={6}>
      <Flex gap={10} direction={{ base: 'column', md: 'row' }}>
        <Image
          src={product.image}
          alt={product.name}
          borderRadius="lg"
          maxW="400px"
          objectFit="cover"
        />
        <Stack spacing={4} flex={1}>
          <Badge colorScheme="green" w="fit-content">
            {product.stock > 0 ? 'In Stock' : 'Out of Stock'}
          </Badge>
          <Heading size="xl">{product.name}</Heading>
          <Text fontSize="2xl" color="teal.600">
            ${product.price.toFixed(2)}
          </Text>
          <Text color="gray.600">{product.description}</Text>
          <Flex align="center" gap={4}>
            <NumberInput defaultValue={1} min={1} max={product.stock} w="120px">
              <NumberInputField />
              <NumberInputStepper>
                <NumberIncrementStepper />
                <NumberDecrementStepper />
              </NumberInputStepper>
            </NumberInput>
            <Button colorScheme="teal" size="lg">
              Add to Cart
            </Button>
          </Flex>
        </Stack>
      </Flex>
    </Box>
  );
}

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return {
    paths: products.map((p) => ({ params: { id: String(p.id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/products/${params.id}`);

  if (!res.ok) {
    return { notFound: true };
  }

  const product = await res.json();

  return {
    props: { product },
    revalidate: 60, // Regenerate at most once per 60 seconds
  };
}

With fallback: 'blocking', new product pages are generated on-demand on the first request and then cached. The revalidate: 60 setting means that after 60 seconds, the next request triggers a background regeneration while the stale page is served immediately. This gives users instant loads with data that stays reasonably fresh.

Best Practices

Always Extract Critical CSS

The most common mistake developers make with Chakra UI and SSR is forgetting the _document.js style extraction. Without it, the server sends unstyled HTML and the browser must wait for the JS bundle to load before applying styles. Always include the extractCriticalToChunks logic shown above.

Handle Color Mode Correctly

Color mode is the trickiest part of SSR with Chakra UI. The server doesn't know the user's preferred color mode, so it renders with the initialColorMode from your theme config. The ColorModeScript in _document.js then synchronously reads localStorage before React hydrates, preventing a flash. Never try to read color mode from localStorage during getServerSideProps or getStaticProps โ€” that code runs on the server where localStorage doesn't exist.

Avoid Hydration Mismatches

Hydration mismatches occur when the server-rendered HTML differs from what React expects on the client. Common causes with Chakra UI include:

Use the App Router Carefully

If you're using the Next.js App Router (the app/ directory), the setup differs. You wrap providers in a app/providers.js client component and import it into your app/layout.js. The Emotion cache setup remains similar, but _document.js is not used. Instead, you handle style injection through the useServerInsertedHTML hook from next/navigation.

// app/providers.js (App Router)
'use client';

import { CacheProvider } from '@emotion/react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import createEmotionCache from '../lib/chakra-cache';
import { useServerInsertedHTML } from 'next/navigation';

const cache = createEmotionCache();
const theme = extendTheme({
  config: { initialColorMode: 'light', useSystemColorMode: false },
});

export function Providers({ children }) {
  useServerInsertedHTML(() => {
    const entries = Object.entries(cache.inserted);
    if (entries.length === 0) return null;

    const names = entries
      .filter(([name]) => name !== 'undefined')
      .map(([name]) => name);

    const styles = entries.map(([, css]) => css).join(';');

    return (
      <style
        data-emotion={`css ${names.join(' ')}`}
        dangerouslySetInnerHTML={{ __html: styles }}
      />
    );
  });

  return (
    <CacheProvider value={cache}>
      <ChakraProvider theme={theme}>
        {children}
      </ChakraProvider>
    </CacheProvider>
  );
}

Optimize Bundle Size

Chakra UI is powerful but can add weight to your bundle. To keep things lean, import only the components you use (tree-shaking handles this automatically with named imports), and consider using @chakra-ui/cli to generate type definitions if you're customizing the theme extensively. Also, lazy-load heavy interactive components like modals and drawers with next/dynamic when they're not needed on initial render.

Test All Three Strategies

Don't assume that because SSR works, SSG and ISR will too. Each rendering strategy exercises the Emotion cache slightly differently. Build your project with npm run build and verify that static pages are generated, ISR pages have revalidation configured, and SSR pages render on each request. Check the network tab to confirm that critical CSS is present in the initial HTML response.

Conclusion

Server-side rendering with Chakra UI is straightforward once you understand the Emotion cache pipeline and the role of _document.js in extracting critical CSS. Whether you choose SSR for dynamic, user-specific content, SSG for static marketing pages, or ISR for content that updates periodically, the same provider and cache setup works across all three strategies. The key takeaways are: always extract styles server-side, use ColorModeScript to prevent theme flashing, guard against hydration mismatches, and test each rendering mode after building. With these patterns in place, you get the best of both worlds โ€” Chakra UI's excellent developer experience and the performance benefits of server-rendered HTML delivered instantly to your users.

โ€” Ad โ€”

Google AdSense will appear here after approval

โ† Back to all articles