Rendering Twig templates in Storybook
Tags: storybook, symfony, php
Published: 2023-03-11 21:54:59

> Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It’s open source and free.

**Update: this is now easier to use Storybook with Twig Components. [Read this article](https://dev.to/sensiolabs/how-to-share-your-twigcomponent-with-your-team--54b4) from Mathéo.**

Historically, it requires a frontend framework like React, Vue or Angular to render components. But I build websites with Twig, so I needed a way to render the templates in my backend application. The package [`@storybook/server`](https://www.npmjs.com/package/@storybook/server) is a solution for this use-case. It was released in March 2022 and is exactly what its name suggests: a server-side rendering solution for Storybook.

There are very few things to do to connect Storybook to a Symfony application, this is a step-by-step guide to get you started. I'm assuming that you already have a Symfony application with Twig installed, and that you're using [webpack encore](https://symfony.com/doc/current/frontend.html). But that's not a requirement, you can use any other frontend tool you like.

## Creating the first component

In the websites I build, a components is a single Twig file that can be included in any other template of the project. This can also be a [Twig Component](https://symfony.com/bundles/ux-twig-component/current/index.html) if you are using [Symfony UX](https://symfony.com/doc/current/ux.html). For this example, we'll create a simple button component, in a `templates/components` folder. This button can have a label, a size and can be primary or secondary.

```twig
{# templates/components/button.html.twig #}

<button
    type="button"
    class="{{ size }} {{ primary|default(false) ? 'primary' : 'secondary' }}"
    style="{{ style|default('')|escape('html_attr') }}"
>
    {{- label -}}
</button>
```

Our goal is to provide a preview of this component in storybook, with the possibility to change its properties.

## Creating the story

In StorybookJS, [stories](https://storybook.js.org/docs/html/get-started/whats-a-story) are individual states of components that can be displayed in isolation for preview and testing. They are typically written in JavaScript when rendering is done on the client-side with a javascript function. For server-side rendering, stories can be written in JSON. An additional key `parameters.server.id` is required to specify the path to the preview.

```json
// stories/button.json
{
  "title": "Components/Button",
  "parameters": {
    "server": { "id": "button" }
  },
  "args": { "label": "Button" },
  "argTypes": {
    "label": { "control": "text" },
    "primary": { "control": "boolean" }, 
    "backgroundColor": { "control": "color" },
    "size": {
      "control": { "type": "select", "options": ["small", "medium", "large"] }
    }
  },
  "stories": [
    {
      "name": "Primary",
      "args": { "primary": true }
    },
    {
      "name": "Secondary"
    },
    {
      "name": "Large",
      "args": { "size": "large" }
    }
  ]
}
```

The Storybook will look like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cwt5qt3j3iyn7q3d2sl1.png)



## Installing Storybook server

The [`@storybook/server` documentation](https://www.npmjs.com/package/@storybook/server) recommends using of [`npx`](https://www.npmjs.com/package/npx) to initialize the project with the `server` type:

```bash
npx sb init -t server
```

Symfony Webpack Encore uses Webpack5 while Storybook is uses Webpack4 by default, so we need to install the `@storybook/builder-webpack5` package:
```bash
npm install @storybook/builder-webpack5
```

This will install npm packages and create a `.storybook` folder with `main.js` and `preview.js`.

The file `.storybook/main.js` defines the location of the stories and the addons to be used. We need to add the `@storybook/server` framework and the `@storybook/builder-webpack5` builder because webpack encore uses webpack5.

```js
// .storybook/main.js
module.exports = {
    "stories": [
        "stories/**/*.stories.mdx",
        "stories/**/*.stories.@(json)"
    ],
    "addons": [/* optional addons */],
    "framework": "@storybook/server",
    "core": {
        // Webpack Encore uses webpack5
        "builder": "@storybook/builder-webpack5"
    }
}
```

Last configuration step, we need to configure the URL where the application is served (by default symfony-cli serves on port 8000). The url is arbitrary, it can be anything you want, but it must match the route defined in the Symfony application. Storybook server will concatenate the url with the `parameters.server.id` value to get the full url to the component. It must be a valid absolute url.

```js
// .storybook/preview.js
export const parameters = {
  server: {
    url: `https://localhost:8000/storybook/component`,
  },
};
```

## Creating a generic Symfony controller

Back to the Symfony application, we need to create a controller behind the url defined in the Storybook configuration. It gets the `id` from the url and the `args` from the query string.

```php
<?php # src/Controller/StorybookController.php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;

#[AsController]
readonly class StorybookController
{
    public function __construct(private Environment $twig) {}

    #[Route(
        '/storybook/component/{id}',
        // The id can contain slashes, so we need to use a regex
        requirements: ['id' => '.+'],
    )]
    public function __invoke(Request $request, string $id): Response
    {
        // $id is the path to the Twig template in the storybook/ directory
        // Args are read from the query parameters and sent to the template
        $template = sprintf('storybook/%s.html.twig', $id);
        $context = ['args' => $request->query->all(), 'id' => $id];
        $content = $this->twig->render($template, $context);

        // During development, storybook is served from a different port than the Symfony app
        // You can use nelmio/cors-bundle to set the Access-Control-Allow-Origin header correctly
        $headers = ['Access-Control-Allow-Origin' => 'http://localhost:6006'];

        return new Response($content, Response::HTTP_OK, $headers);
    }
}
```

## Creating a Twig template to render the component

We have created our component in `templates/components/button.html.twig` and we configured the stories in `stories/button.json`. Now we need to create a Twig template that will render the component in the context of Storybook. This template will be used by the controller we created in the previous step.

```twig
{# templates/storybook/button.html.twig #}

{{ include('components/button.html.twig', {
    label: args.label|default('Button'),
    size: args.size|default('medium'),
    primary: args.primary|default(false) == 'true',
    style: args.backgroundColor is defined ? "background-color: #{args.backgroundColor}" : '',
}) }}
```

## Running Storybook

Finally, we can run Storybook and see our component in action.

```bash
npm run storybook

> storybook
> start-storybook -p 6006
...
╭───────────────────────────────────────────────────╮
│                                                   │
│   Storybook 6.5 for Server started                │
│   4.56 s for preview                              │
│                                                   │
│    Local:            http://localhost:6006/       │
│    On your network:  http://192.168.1.11:6006/    │
│                                                   │
╰───────────────────────────────────────────────────╯
```

Time to play with the component and see how it behaves in different states.

## Conclusion

This was my first experience with Storybook, and I'm very satisfied with the result. I hope this article helps you to get started and if you need to convince your frontend team that you don't need to switch to Node to use Storybook. Symfony is an amazing framework for building rich web applications and the need for a frontend framework to build the rich UI is being challenged by the [Symfony UX](https://ux.symfony.com/) initiative. 

If you have any questions or feedback, feel free to comment below or [reach out to me on Twitter](https://twitter.com/GromNaN).