Setup NextJS app with Linaria, Storybook, and Typescript
In this article, we will show how to start a new modern performance-focused and development-friendly project from scratch.
In this article, we will show how to start a new modern performance-focused and development-friendly project from scratch. It will be built based on the latest versions of the following libraries and frameworks:
- NextJS
- Linaria
- Storybook
- Typescript
Why do we even want to build our application using these tools? First of all, we need our project to meet modern requirements for web applications, and this is, first of all, good performance optimization and SEO support. In particular, we want to achieve good Core Web Vitals metrics. At the same time, we want to get excellent code quality, development speed, and DX. We at Focus Reactive are constantly researching ways to improve the UX of our projects, and based on the above requirements, we prefer to use the following set of development approaches:
- Static generation of everything that can be generated in advance
- Maximum attention to detail that affects the user experience and page loading speed
- Using the best development practices to organize the coordinated work of several engineers and make fewer mistakes
Before continuing, let's take a quick look at the proposed technology stack and explain how each of the tools helps us achieve our goals.
NextJS is a framework of our choice. It has a huge set of features and advantages, but at the same time quite simple and flexible in configuration. We will not list all the advantages of NextJS, as this is a topic for a separate extensive article. We like NextJS for combining SSR and SSG in the same project, ISR, server and client navigation, folder-based routing, and performance optimization tools.
The CSS framework. As stated on their website Zero-Runtime CSS in JS. Let's see why this is important to us. As mentioned in our list of approaches, we prefer to have a statically generated set of CSS styles for our application. This is important for fast page loading and rendering. The lack of runtime has a beneficial effect on FCP (first contentful paint) and CLS (cumulative layout shift). Such CSS files can be cached on a CDN. At the same time, Linaria is a CSS in JS framework and provides us with syntax and features similar to Styled Components and EmotionJS. First of all, this is the utilizing of "styled-components" with dynamic props, theming, and so on.
This development tool allows us to organize work and communication with the client. Thanks to the ability to develop application components in isolation, we can parallelize the work of our engineers and have a fast approval cycle for individual parts of the project.
Typescript needs no introduction. With types, our team can develop the application more confidently.
This tech stack is excellent for creating marketing sites with a massive number of well-optimized landing pages, blogs, and so on. Typically, this approach goes well with a headless CMS for content creation. We won't touch on how to pair a project with any particular CMS, but if you want to read more about CMS like Contentful, Sanity, Storyblok, or Hygraph, you can find articles about them on our blog
Having the list of such awesome tools, how difficult is it to wire everything together?
Well, it's not as easy as just adding dependencies to a project. But we are here to deal with all this and, at the same time, to understand what features of the libraries affect the integration process.
Setup NextJS and Linaria
Let's start with a clean slate and create the basis for the future project - set NextJS in a new folder:
yarn create next-app --typescript
- I prefer
yarn
, but you can also get the same withnpm
if you prefer.
- I prefer
We have added the --typescript option to have typescript support right from the beginning. Now you can start a new project in dev mode:
yarn dev
And then open the home page in the browser
Now let's add Linaria. For this we need the packages:
- @linaria/core
- @linaria/react
- @callstack/react-theme-provider
- next-linaria
The last one is a helper package that will help us set up the NextJS configuration. The fact is that Linaria comes with own Webpack plugins and Babel presets. NextJS has its own Webpack settings and in order to merge them together we will use the withLinaria
utility from the next-linaria
package.
yarn add @linaria/core @linaria/react @callstack/react-theme-provider next-linaria@beta
Note the @beta
tag for the next-linaria package. It is required to support the latest versions of Linaria and NextJS. At the time of writing this is:
"next": "13.0.4",
"@linaria/core": "^4.2.2",
"@linaria/react": "^4.3.0",
"next-linaria": "^1.0.1-beta",
"@callstack/react-theme-provider": "^3.0.8"
Create a new file at the root of the project and name it next-linaria.config.js
. This is where we will keep the NextJS and Linaria settings. We will also need to transfer all the settings from next.config.js
to this file.
// next-linaria.config.js
const withLinaria = require('next-linaria');
const webpackConfig = withLinaria({
// settings from next.config.js
reactStrictMode: true,
swcMinify: true,
});
module.exports = webpackConfig;
Then edit next.config.js to look like this:
// next.config.js
/** @type {import('next').NextConfig} */
const linariaConfig = require('./next-linaria.config');
module.exports = linariaConfig;
We also need to extend the .babelrc
file in your project. It should look like this:
{
"presets": ["next/babel", "@linaria"],
"plugins": []
}
Now we can start creating Styled Components for our project. For example, like this:
// any component file
import React from 'react';
import { styled } from '@linaria/react';
const Button = styled.button<{color: string}>`
border: 1px solid #333333;
color: white;
background-color: ${({color}) => color};
`
Note that we can use dynamic props to pass parameters to the component (color in our example). Linaria, unlike other CSS-in-JS libraries, turns such props into CSS variables. This provides the ability to dynamically change parameters with minimal js. For the developer, it looks like a familiar and convenient syntax.
Theming
Another useful feature of Linaria is that it supports theming. With the help of themes, we can dynamically switch the appearance of the application. It's also a handy way to organize CSS properties in one place and reuse them in different components.
To use theming, we need a theme provider that will pass theme data for all "styled components" via React context. Let's start by creating a simple theme object that will store the colors used in the project.
// theme.ts
const lightTheme = {
colors: {
accent100: "#F1A9A7",
accent200: "#E4534E",
accent300: "#B1201B",
white: '#FFFFFF',
black: '#333333',
}
}
You can add more useful information here later, but for now, these values in the colors
section are enough for us.
Now let's create a theme provider.
// theme.ts
import React from 'react';
import { createTheming } from '@callstack/react-theme-provider';
// …
const theming = createTheming(lightTheme);
export const ThemeProvider = ({
children,
theme = lightTheme,
}) => {
return (
<theming.ThemeProvider theme={theme}>{children}</theming.ThemeProvider>
);
};
Note that we are passing the theme object through the props in the theme provider. This is necessary if you plan to have multiple themes in the application. For example dark and light. In other cases, this is not required and the theme object can be simply hardcoded inside the provider. How can we now consume theme values in our components? Linaria does not pass the theme object into components along with props, as other libraries do. The documentation says that you can use the hook generated when the theme is created:
theming.useTheme()
However, there is a problem with the fact that we cannot just use this expression in a styled component directly. Indeed, if we try to do something like this:
const Button = styled.button<{ color: string }>`
border: 1px solid #333333;
color: ${theming.useTheme().colors.accent200};
background-color: ${({ color }) => color};
`;
Then when compiling we get the following message:
An error occurred when evaluating the expression:
> Cannot read properties of null (reading 'useContext').
Make sure you are not using a browser or Node specific API and all the variables are available in static context.
Linaria have to extract pieces of your code to resolve the interpolated values.
Defining styled component or class will not work inside:
- function,
- class,
- method,
- loop,
because it cannot be statically determined in which context you use them.
That's why some variables may be not defined during evaluation.
12 | const Button = styled.button<{ color: string }>`
13 | border: 1px solid #333333;
> 14 | color: ${theming.useTheme().colors.accent200};
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
15 | background-color: ${({ color }) => color};
16 | `;
17 |
at transformFile.next (<anonymous>)
at run.next (<anonymous>)
at Generator.next (<anonymous>)
To solve this problem, we will use a small workaround. We will create a helper function that will use the hook internally. Let's go back to the theme.ts file and add the following code:
// theme.ts
// …
export const tm = (cb) => () =>
((fn) => fn(theming.useTheme()))(cb);
Now we can use this function inside components:
// any component file
import React from 'react';
import { styled } from '@linaria/react';
Import { tm } from ‘./theme’;
const Button = styled.button<{color: string}>`
border: 1px solid #333333;
color: ${tm((t) => t.colors.accent200)};
background-color: ${({color}) => color};
`
Let's spice everything with typings
One of the benefits of having all the data come from a single theme object is that it's convenient for developers to write code using auto-completion. To add such an option to ourselves, let's describe the types used in the provider theme. I will show the final version of the theme.ts
file:
// theme.ts
export const lightTheme = {
colors: {
accent100: "#F1A9A7",
accent200: "#E4534E",
accent300: "#B1201B",
white: '#FFFFFF',
black: '#333333',
}
}
export type Theme = typeof lightTheme;
type ThemeProviderProps = {
theme?: Theme;
children: JSX.Element;
};
export const ThemeProvider = ({
children,
theme = lightTheme,
}: React.PropsWithChildren<ThemeProviderProps>): JSX.Element => {
return (
// @ts-ignore
<theming.ThemeProvider theme={theme}>{children}</theming.ThemeProvider>
);
};
type ThemeCallback<T> = (tm: T) => string;
export const tm = (cb: ThemeCallback<Theme>) => () =>
((fn) => fn(theming.useTheme()))(cb);
Now, when we write the component code, the IDE will prompt us to choose from the existing values. Typing opens up another convenient option if we want to pass a color through props into the component, but at the same time limit ourselves to the values set in the theme. For example, in our button, we set the background-color property this way. Let's change the code so that only the values set in the theme can be specified:
// any component file
import React from 'react';
import { styled } from '@linaria/react';
Import { tm, lightTheme, Theme } from ‘./theme’;
const Button = styled.button<{ color: keyof Theme[‘colors’] }>`
border: 1px solid #333333;
color: ${tm((t) => t.colors.accent200)};
background-color: ${({ color }) => lightTheme.colors[color]};
`
Easy peasy! Now, when using Button somewhere in the code, the IDE will show the entire list of available values for the color prop.
Improving DX with Storybook
Storybook is a great development tool. It allows you to create and inspect components in an isolated environment, easily switching between the desired states. Why is this important to our work? First, we can design various interface elements without placing them on specific pages that may not have been created yet. Secondly, Storybook allows us to inspect a component in those states, which are hard to reproduce in an actual application. For example, if we have a component with a sequence of steps that can require filling forms, communication with the backend, and so on - it might be not very convenient to develop the last step of this component when it is inserted into a real page. So that the developer does not have to manually reproduce this step each time, we can emulate this state of the component in the Storybook. Thirdly, with the help of the Storybook, it is more convenient for us to organize demos with a client or pass components for review.
Let's add a Storybook to our project and configure it to support Linaria and our features. To do this, we need to run the command in the terminal from the root of our project:
npx storybook init
The installation script will detect the type of our project, install the needed dependencies, add scripts to package.json and create some sample story files. Also, we need to install an addon to work with themes:
yarn add –dev @react-theming/storybook-addon
Usually, after installation I change the storybook startup script as follows:
"storybook": "start-storybook -p 6006",
"storybook": "start-storybook -p 6006 --ci",
The --ci key suppresses the automatic browser opening on starting the Storybook. I prefer to do it myself, but you can keep this behavior as is if you like it.
We can now launch the Storybook and see the sample stories in the browser http://localhost:6006/. Let's configure our Storybook to work with the project. The first thing to do is to remove the added sample stories, having previously studied these examples. Instead, let’s add a story file for out Button component:
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Button from './Button';
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: 'UI Elements/Button',
component: Button,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
} as ComponentMeta<typeof Button>;
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Default = Template.bind({});
Default.storyName = 'Default button';
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Default.args = {
children: 'Primary Button',
};
In order for all our components to be displayed correctly in the Storybook, we need to add Linaria support to the Webpack config. Remember how we did this for NextJS? Here we need to do the same. The fact is that the Storybook works and builds itself independently of NextJS. It has another entry point and, accordingly, its own Webpack config. And our task is to add to the Storybook all NextJS and Linara features. Luckily, however, we don't need to set this entire config manually, because we can simply extend it with the same file that we used for Next.
Open .storybook/main.js
and add the following changes to it:
const nextConfig = require('../next-linaria.config');
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@react-theming/storybook-addon',
'@storybook/addon-essentials',
'@storybook/addon-links',
'@storybook/addon-interactions',
],
framework: '@storybook/react',
webpackFinal: async (baseConfig) => {
return nextConfig.webpack(baseConfig, {});
},
core: {
builder: '@storybook/builder-webpack5',
},
};
We also included react-theming in the add-ons list.
Now we can add stories for the components created using linaria and the Storybook will display them nicely.
However, we still need to wrap all the components in a theme provider so that the components can access the values set in the theme. Let's make these changes to .storybook/preview.js
:
// .storybook/preview.js
import { withThemes } from '@react-theming/storybook-addon';
import { ThemeProvider, lightTheme } from '../theme';
// …
const themingDecorator = withThemes(ThemeProvider, [lightTheme]);
export const decorators = [themingDecorator];
This is enough to fully work with our components in the Storybook, but we can improve our DX a little more if we specify in the theming add-on how we insert values from the theme into the component. The Theming addon generates small snippets based on selected theme fields that you can copy-paste to your component. By default, it does this in a format suitable for Styled Components. However, we can easily customize this:
// .storybook/preview.js
import { withThemes } from '@react-theming/storybook-addon';
import { ThemeProvider, lightTheme } from '../theme';
// …
const getCustomFieldSnippet = (selectedValue) => {
const { namespace, name } = selectedValue;
const path = namespace.join('.');
const fullPath = `${path}.${name}`;
const themeProp = `\${tm((t) => t.${fullPath})};`;
return themeProp;
};
const themingDecorator = withThemes(ThemeProvider, [appTheme], {
getCustomFieldSnippet,
});
export const decorators = [themingDecorator];
Now when selecting a color on the addon panel, we will have a code snippet to insert into the component to consume that field.
Conclusion
We shared our way of setting up and organizing work in a project. We'd love to hear your feedback on this article. Share your way of working with this stack. If you are interested in more articles on NextJS, Storybook and Headless CMS welcome to our blog. Also, feel free to contact us if you need our expertise or assistance in development.