Skip to main content

React setup for Sulu headless

This blog post might be outdated!
This blog post was published more than one year ago and might be outdated!
· 6 min read
Daniel Ruf

We are currently building our first website based on Sulu. For this, we use the Sulu headless bundle to focus on React as technology on the frontend side.

At first, we evaluated and tried different solutions which brought their own challenges and problems. To be more clear, our current frontend techstack with Next.js was too opinionated for this project, which resulted in much more overhead, than we had initially anticipated.

This resulted in a technical discussion in our frontend team, about what the best way would be to implement our frontend tech stack with the least possible amount of effort and good DX (developer experience).

As frontend team, we are constantly evaluating new solutions and checking, if our tech stack still covers our requirements and provides solutions to our daily challenges as developers. Sometimes we adjust our long-term strategy based on the progress of the frontend ecosystem.

One of these solutions is SWC, which is an extremely fast JavaScript compiler written in Rust and so far was not yet used in our projects (Next.js itself uses esbuild and so does our alternative solution Vite).

So we wanted to give it a try and implement the rest of our tech stack in a simple project setup from scratch.

For a brief overview, what we regularly use:

eslint and prettier are applied on every commit via lint-staged and husky

In the past, we have used CRACO (configurable create-react-appwithout the need to eject) which uses webpack. So there is also some experience with webpack (4 and 5).

That leads us to the following additional dependencies for this project:

The internal routing based on the current page and page template was handled in a simple switch-case statement (in the view folder) and the following structure (excerpt):

src
| components (React components grouped by Sulu type)
| | blocks
| | ...
| | snippets
| i18n (our translation config and data)
| pages (named like the used Sulu templates)
| | homepage.tsx
| | default.tsx
| | ...
| types (TypeScript types)
| utils (different utilities)
| views (data handling and routing)

configuration files

These are the configuration files that we use in the project:

.eslintignore

.eslintrc.json
webpack.config.js

.eslintrc.json

{
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"extends": [
"airbnb", // https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb
"airbnb-typescript", // https://github.com/iamturns/eslint-config-airbnb-typescript
"eslint-config-prettier" // https://github.com/prettier/eslint-config-prettier#installation
],
"parserOptions": {
"files": ["*.ts", "*.tsx"], // Your TypeScript files extension
"project": "./tsconfig.json"
},
"settings": {
"react": {
"version": "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
}
},
"rules": {
"react/jsx-props-no-spreading": "off",
"react/function-component-definition": [
2,
{
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}
],
"default-case": ["error", { "commentPattern": "^no\\sdefault" }]
}
}

.gitignore

#eslint
.eslintcache

.husky/pre-commit

#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

cd assets/headless
yarn lint-staged

.prettierignore

build
coverage

.prettierrc

{
"semi": true,
"printWidth": 80,
"tabWidth": 2,
"singleQuote": true,
"bracketSpacing": true,
"importOrder": [
"^components/(.*)$",
"^i18n/(.*)$",
"^pages/(.*)$",
"^types/(.*)$",
"^utils/(.*)$",
"^views/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"jsxBracketSameLine": false,
"useTabs": false,
"arrowParens": "avoid",
"jsxSingleQuote": true,
"trailingComma": "all"
}

.swcrc

{
// "minify": true,
"jsc": {
// "minify": {
// "compress": true
// },
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": true
// "dynamicImport": true
},
"transform": {
"react": {
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": false
},
"optimizer": {
"globals": {
"vars": {
"__DEBUG__": "true"
}
}
}
},
"externalHelpers": false
}
}

package.json (excerpt)

Removed some sulu-headless dependencies for betetr readability

{
"name": "bitexpert-sulu-react",
"main": "src/index.ts",
"private": true,
"scripts": {
"build": "NODE_ENV=production webpack",
"watch": "NODE_ENV=development webpack -w",
"lint": "eslint ./src",
"prepare": "cd ../.. && husky install assets/headless/.husky"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
// small workaround for us with yarn, see https://github.com/sulu/SuluHeadlessBundle/issues/113
"sulu-headless-bundle": "file:../../vendor/sulu/headless-bundle/Resources/js-website",
"typescript": "^4.7.3",
"zod": "^3.17.3"
},
"devDependencies": {
"@swc/cli": "^0.1.57",
"@swc/core": "^1.2.197",
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
"@types/react": "^18.0.14",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"core-js": "^3.0.0",
"css-loader": "^6.7.1",
"eslint": "^8.17.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.5.0",
"eslint-webpack-plugin": "^3.1.1",
"history": "^4.10.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"loglevel": "^1.0.0",
"mini-css-extract-plugin": "^2.6.0",
"mobx": "^4.0.0",
"mobx-react": "^5.0.0",
"postcss": "^8.4.14",
"postcss-loader": "^7.0.0",
"postcss-preset-env": "^7.7.1",
"prettier": "^2.6.2",
"prop-types": "^15.7.0",
"style-loader": "^3.3.1",
"swc-loader": "^0.2.3",
"tailwindcss": "^3.1.2",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
"whatwg-fetch": "^3.0.0"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --cache --fix",
"*.{ts,tsx,css}": "prettier --write"
}
}

postcss.config.js

module.exports = {
plugins: {
'postcss-preset-env': {},
tailwindcss: {},
autoprefixer: {},
}
}

tailwind.config.js (excerpt)

/** @type {import('tailwindcss').TailwindConfig} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
plugins: [],
};

tsconfig.json

{
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"components": ["components"],
"i18n": ["i18n"],
"pages": ["pages"],
"types": ["types"],
"utils": ["utils"],
"view": ["views"],
}
},
"include": ["src"],
"exclude": ["node_modules"]
}

webpack.config.js

const path = require('path');
const ESLintPlugin = require('eslint-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const isProduction = process.env.NODE_ENV === 'production';

module.exports = () => ({
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
entry: './src/index.ts',
output: {
clean: true,
// path: path.resolve(__dirname, '../../public/build/headless/js/'),
path: path.resolve(__dirname, '../../public/build/headless/'),
assetModuleFilename: 'media/[name].[hash][ext]',
// for prod we might want to use some hash value or .min.js
filename: isProduction ? 'js/index.js' : 'js/index.js',
chunkFilename: isProduction
? 'js/[name].[contenthash:8].chunk.js'
: 'js/[name].chunk.js',
},
module: {
rules: [
{
test: /\.(js|ts|tsx)$/,
// exclude: /node_modules/, // disabled as we need this for the sulu-headless-bundle package
use: {
// `.swcrc` can be used to configure swc
loader: 'swc-loader',
// options: {
// cacheDirectory: true,
// },
},
},
{
test: /\.css$/i,
include: [
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'node_modules'),
],
use: [
// !isProduction ? "style-loader" : MiniCssExtractPlugin.loader,
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader"
],
},
],
},
plugins: [
new ESLintPlugin({
extensions: ['ts', 'tsx'],
}),
].concat(
!isProduction
? [ new MiniCssExtractPlugin({
filename: 'css/[name].css'
})]
: [ new MiniCssExtractPlugin({
filename: 'css/[name].css'
})]
),
resolve: {
alias: {
components: path.resolve(__dirname, 'src/components/'),
views: path.resolve(__dirname, 'src/views/'),
types: path.resolve(__dirname, 'src/types/'),
utils: path.resolve(__dirname, 'src/utils/'),
i18n: path.resolve(__dirname, 'src/i18n/'),
},
preferRelative: true,
extensions: ['*', '.js', '.ts', '.tsx'],
},
});

Sulu patch for yarn

We had to apply a small patch via composer-patches to prevent issues with a missing version number in the headless bundle:

--- Resources/js-website/package.json	2022-06-13 15:54:53.415344409 +0200
+++ Resources/js-website/package.json 2022-06-13 15:54:46.431406866 +0200
@@ -2,6 +2,7 @@
"name": "sulu-headless-bundle",
"description": "A collection of react components to build a headless frontend based on Sulu",
"main": "index.js",
+ "version": "0.0.0",
"private": true,
"scripts": {
"build": "webpack index.js -o build/index.js --module-bind js=babel-loader -p --display-modules --sort-modules-by size",