Why Go Headless First

Traditional CMS platforms were built for a web that no longer exists. Their coupled frontend and backend make them rigid, project-focused tools rather than adaptable products. Headless CMS platforms solve this by separating content management from presentation entirely — the "head" (frontend) is decoupled from the "body" (content repository), letting developers choose any frontend stack and design their presentation layer freely.

These API-based platforms are built on different underlying technologies. Sanity.io, for instance, is constructed with React.js, while Storyblok runs on Vue.js. But because they're headless, any frontend can plug into any of them. Several services like Strapi, Contentful, and Sanity offer free tiers for small projects, though paid plans often involve significant price jumps. Sanity's pay-as-you-go pricing model addresses this — you only pay for what you actually use.

Sanity also ships GROQ (Graphical-Relational Object Queries), a query language that reduces development time and lets you fetch content in exactly the shape you need. It can even create documents with new content models without code changes. That said, developers aren't locked into GROQ — GraphQL, axios, and fetch all work for querying the backend.

Note: This article assumes basic familiarity with React, Redux, and CSS.

Setting Up Sanity

Start by installing the Sanity CLI globally so it's available for all future projects.

npm install -g @sanity/cli

The -g flag handles the global installation. Next, initialize Sanity inside your React app — keeping it within your frontend project is the preferred setup over running it as a standalone. Sanity's docs on integrating with React are comprehensive and worth reviewing before proceeding.

sanity init

During initialization, follow the prompts to customize your project and create a dataset. For this listing app, we'll use Sanity's pre-populated sci-fi movies dataset so we don't have to enter content manually.

To browse and edit your dataset, cd into the Sanity subdirectory and run sanity start, which typically serves at https://localhost:3333/. You may need to log in with the same credentials used during initialization.

Sanity server overview
An overview of the sanity server for the sci-fi movie dataset. (Large preview)

Establishing Communication

A complete app requires Sanity and React to exchange data in both directions. First, point your React frontend at the Sanity backend by logging into https://manage.sanity.io/ and navigating to CORS origins under API Settings in the Settings tab. Add https://localhost:3000/ — the default origin for React apps — to the allowed origins list.

CORS origin settings
Setting CORS origin in Sanity.io Manager. (Large preview)

Every Sanity project has a unique project ID, which you'll find in your Sanity Manager. That ID is what connects your frontend to the backend. The sanity client library facilitates this link — install it first:

npm install @sanity/client

Create a file like sanitySetup.js in your src folder and add the connection logic:

import sanityClient from "@sanity/client"
export default sanityClient({
    projectId: PROJECT_ID,
    dataset: DATASET_NAME,
    useCdn: true
});

Passing projectId, a dataset name, and the useCdn boolean to the client instance completes the connection between your React app and Sanity.

Wiring Up Redux

Redux needs a few supporting packages. Install them in your React environment:

npm install redux react-redux redux-thunk

Redux manages global state across frameworks, but react-redux is required to bridge the gap between the Redux store and React components. Meanwhile, Redux thunk lets you return functions from actions instead of just action objects — usually for handling asynchronous logic.

Rather than cramming everything into one file, separate concerns into distinct modules: actions, reducers, store, plus a file for action types (or constants).

Creating The Store

The store packages and distributes state to the React application. Set it up like this:

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducers from "./reducers/";

export default createStore(
  reducers,
  applyMiddleware(thunk)
);

createStore takes three arguments: the reducer (mandatory), the initial state, and an enhancer. In this case, the enhancer is thunk middleware applied via applyMiddleware. Reducers live in a dedicated folder, combined and exported from a single index.js file — that's the import shown in the code above. We'll return to that file shortly.

Building the Data Layer with Constants, Actions, and Reducers

With the Sanity backend configured, the next step is establishing the Redux data flow: constants to track states, actions to manage logic, and reducers to handle responses.

Defining Constants Files

Constants track action types across the Redux workflow, indicating when a request is loading, successful, or failed. Although not strictly required, separating constants into dedicated files is standard practice for clarity and maintainability. Following JavaScript convention, constants use uppercase naming and are exported for global access.

We maintain separate constant files for different data domains:

  • movieConstants.js — tracks fetching, sorting, and popular-movie requests
  • personConstants.js — handles person-fetching and counting operations
  • globalConstants.js — manages theme toggling between light and dark modes

Writing Actions with GROQ Queries

Actions are organized by type and stored separately, with movieActions.js, personActions.js, and globalActions.js handling their respective domains. Each action imports the sanitySetup.js connection and relevant constants.

For data retrieval, these actions use GROQ instead of traditional HTTP clients like Axios or Fetch. The sanityAPI.fetch() method initiates GROQ queries and returns a Promise, necessitating asynchronous handling. While the codebase uses async-await, the .then pattern works equally well.

Since the application uses thunk middleware, actions can return functions rather than plain action objects. Each action dispatches a loading-state constant, executes the GROQ query, and returns the result as a payload or captures errors.

Key GROQ patterns in the movie actions include:

  • Fetch all movies — selects all documents of type movie, returning _id and poster URL
  • Fetch movies by reference — matches person._ref in either castMembers or crewMembers, returning id, poster, and title
  • Fetch single movie — retrieves a document by id with nested attributes from castMembers and crewMembers, including ref, characterName, person name and image, aliased to cast and crew
  • Sort movies — uses the pipe operator (|) with order, accepting parameters for field and sort direction
  • Get popular movies — sorts by popularity in descending order with a zero-based index range like [0..2] (to retrieve more items, extend the range, e.g., [0..9] for ten)

Building Reducers

Reducers compare incoming action types via switch statements and return updated states. Each reducer accepts the initial state and action as arguments, following the standard signature. When a successful fetch completes, loading is set to false and the payload—stored in a variable like movies—is returned. Error cases return the error payload. Some reducers also provide reset functionality to clear states when necessary.

All reducers in movieReducers.js and personReducers.js follow the same structural pattern, differing only in the constants they reference and the data they return.

Combining Reducers into the Store

Redux's combineReducers utility merges multiple reducers into one. An index.js file inside the reducers folder imports movies, persons, and global reducers, then passes them as an object to combineReducers. Optionally, custom aliases can be applied to these arguments.

The combined reducers flow into the Redux store configuration file (store.js), completing the data-management setup. Reaching this point readies the application for building the React frontend layer.

Booting the React Frontend

The demo app lists movies with their cast and crew. Routing comes from react-router-dom, UI bits and icons from Material UI, and all component-level styling from styled-components. Install everything in one go:

npm install react-router-dom @material-ui/core @material-ui/icons query-string

Wiring Redux Into the Component Tree

react-redux exposes a Provider that makes the store available anywhere in the tree. The cleanest place to mount it is in index.js, wrapping the root component and passing the imported store instance:

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import { Provider } from "react-redux";
import store from "./redux/store";
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

Routing is handled in App.js with BrowserRouter, Switch, and Route. The Header and Footer sit outside the switch so every page shares them, and the whole layout is wrapped in a main element:

import React from "react";
import Header from "./components/Header";
import Footer from "./components/Footer";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import MoviesList from "./pages/MoviesListPage";
import PersonsList from "./pages/PersonsListPage";

function App() {

  return (
      <Router>
        <main className="contentwrap">
          <Header />
          <Switch>
            <Route path="/persons/">
              <PersonsList />
            </Route>
            <Route path="/" exact>
              <MoviesList />
            </Route>
          </Switch>
        </main>
        <Footer />
      </Router>
  );
}
export default App;

Each route maps a path to one of four page files: movieListPage.js, moviePage.js, PersonListPage.js, and PersonPage.js.

Fetching Data From Sanity.io

All the movie list logic lives in MovieListPage.js. Because the data fetch must run on mount, the fetchAllMovies action is dispatched inside useEffect. The useDispatch and useSelector Hooks give the component access to the action creator and to the loading, error, and movies slices that the reducer exposes:

import React, { useEffect } from "react";
import { fetchAllMovies } from "../redux/actions/movieActions";
import { useDispatch, useSelector } from "react-redux";

const MoviesListPage = () => {
  const dispatch = useDispatch();
  useEffect(() => {    
      dispatch(fetchAllMovies());
  }, [dispatch]);

  const { loading, error, movies } = useSelector(
    (state) => state.fetchAllMoviesReducer
  );
  
  return (
    ...
  )
};
export default MoviesListPage;

The full page is more elaborate. On load it dispatches getMostPopular to surface high-popularity titles, and it lets users sort by releaseDate or popularity via a sortMoviesBy action. The query parameters determine whether fetchAllMovies is dispatched with any filters. Each of those actions has its own reducer state, so the component selects the relevant loading, error, and movies slices separately with useSelector:

import React, {useState, useEffect} from 'react'
import {fetchAllMovies, getMostPopular, sortMoviesBy} from "../redux/actions/movieActions"
import {useDispatch, useSelector} from "react-redux"
import Loader from "../components/BackdropLoader"
import {MovieListContainer} from "../styles/MovieStyles.js"
import SortIcon from '@material-ui/icons/Sort';
import SortModal from "../components/Modal"
import {useLocation, Link} from "react-router-dom"
import queryString from "query-string"
import {MOVIES_FETCH_RESET} from "../redux/constants/movieConstants"

const MoviesListPage = () => {
    const location = useLocation()
    const dispatch = useDispatch()    
    const [openSort, setOpenSort] = useState(false)    
        
    useEffect(()=>{
        dispatch(getMostPopular())
        const {order, type} = queryString.parse(location.search)
        
        if(order && type){         
            dispatch({ type: MOVIES_FETCH_RESET })
            dispatch(sortMoviesBy(order, type))
        }else{            
            dispatch(fetchAllMovies())    
        }
        
    }, [dispatch, location.search])
    
    const {loading: popularLoading, 
            error: popularError, 
            movies: popularMovies
    } = useSelector(state => state.getMostPopularReducer)
    
    const { loading: moviesLoading, error: moviesError, movies
        } = useSelector(state => state.fetchAllMoviesReducer)
        
    const { loading: sortLoading, error: sortError, movies: sortMovies
    } = useSelector(state => state.sortMoviesByReducer)
    
    return (
        <MovieListContainer>
            
                <div className="mostpopular">     
                    {
                        popularLoading ? 
                        <Loader />                
                        : popularError ? popularError :               
                        popularMovies && popularMovies.map(movie => (
                            <Link to={`/movie?id=${movie._id}`} 
                                className="popular" key={movie._id} 
                                style={{backgroundImage: `url(${movie.poster})`}}>  
                                <div className="content">
                                    <h2>{movie.title}</h2>
                                    <p>{movie.overview.text.substring(0, 50)}…</p>
                                </div>                                
                            </Link>
                        ))
                    }
                </div>    
                <div className="moviespanel">
                    <div className="top">
                        <h2>All Movies</h2>
                        <SortIcon onClick={()=> setOpenSort(true)} />
                    </div>
                    <div className="movieslist">
                        {
                            moviesLoading ? <Loader />
                            : moviesError ? moviesError
                            : movies && movies.map(movie =>(
                                    <Link to={`/movie?id=${movie._id}`} key={movie._id}>
                                        <img className="movie" src={movie.poster} alt={movie.title} />
                                    </Link>
                            ))
                        }
                        {
                            (
                              sortLoading ? !movies && <Loader />
                                : sortError ? sortError
                                : 
                                sortMovies && sortMovies.map(movie =>(
                                    <Link to={`/movie?id=${movie._id}`} key={movie._id}>
                                        <img className="movie" src={movie.poster} alt={movie.title} />
                                    </Link>
                                ))
                            )
                        }
                    </div>
                </div>      
                    <SortModal 
                        open={openSort}
                        setOpen={setOpenSort}
                    />              
        </MovieListContainer>
    )
}

export default MoviesListPage

Rendering is straightforward: show a loader while any state is pending, surface an error message on failure, and otherwise map over the movie array to display posters. Everything is contained in a MovieListContainer styled component.

Scoped Styling With styled-components

Styled-components works per component, but also has a createGlobalStyle export for site-wide rules. After installing the package:

npm install styled-components

Global styles live in a dedicated styles folder inside src. The globalStyles.js file imports createGlobalStyle and defines shared CSS:

import { createGlobalStyle } from "styled-components";
export const GlobalStyle = createGlobalStyle`
  ...
`

Breakpoints come from a deviceWidth constant defined in definition.js. That file holds media query thresholds used throughout the stylesheets:

import { deviceWidth } from "./definition";

Global rules set overflow to hidden to keep the layout in check:

html, body{
        overflow-x: hidden;
}

The header style relies on the classic fixed-position pattern. The background color is not hardcoded; it reads from styled-components props, which lets each theme pass its own value:

.header{
  z-index: 5;
  background-color: ${(props)=>props.theme.midDarkBlue}; 
  display:flex;
  align-items:center;
  padding: 0 20px;
  height:50px;
  justify-content:space-between;
  position:fixed;
  top:0;
  width:100%;
  @media ${deviceWidth.laptop_lg}
  {
    width:97%;
  }
  ...
}

Theming works because the application root is wrapped in ThemeProvider, so any styled component can access theme variables. Flexbox keeps the header contents aligned, and the breakpoints make it behave on narrow screens. The complete global stylesheet is mostly normal CSS inside a template literal:

import { createGlobalStyle } from "styled-components";
import { deviceWidth } from "./definition";

export const GlobalStyle = createGlobalStyle`
    html{
        overflow-x: hidden;
    }
    body{
        background-color: ${(props) => props.theme.lighter};        
        overflow-x: hidden;   
        min-height: 100vh;     
        display: grid;
        grid-template-rows: auto 1fr auto;
    }
    #root{        
        display: grid;
        flex-direction: column;   
    }    
    h1,h2,h3, label{
        font-family: 'Aclonica', sans-serif;        
    }
    h1, h2, h3, p, span:not(.MuiIconButton-label), 
    div:not(.PrivateRadioButtonIcon-root-8), div:not(.tryingthis){
        color: ${(props) => props.theme.bodyText}
    }
    
    p, span, div, input{
        font-family: 'Jost', sans-serif;       
    }
    
    .paginate button{
        color: ${(props) => props.theme.bodyText}
    }
    
    .header{
        z-index: 5;    
        background-color: ${(props) => props.theme.midDarkBlue};                
        display: flex;
        align-items: center;   
        padding: 0 20px;        
        height: 50px;
        justify-content: space-between;
        position: fixed;
        top: 0;
        width: 100%;
        @media ${deviceWidth.laptop_lg}{
            width: 97%;            
        }               
        
        @media ${deviceWidth.tablet}{
            width: 100%;
            justify-content: space-around;
        }
        a{
            text-decoration: none;
        }
        label{
            cursor: pointer;
            color: ${(props) => props.theme.goldish};
            font-size: 1.5rem;
        }        
        .hamburger{
            cursor: pointer;   
            color: ${(props) => props.theme.white};
            @media ${deviceWidth.desktop}{
                display: none;
            }
            @media ${deviceWidth.tablet}{
                display: block;                
            }
        }  
                 
    }    
    .mobileHeader{
        z-index: 5;        
        background-color: ${(props) =>
          props.theme.darkBlue};                    
        color: ${(props) => props.theme.white};
        display: grid;
        place-items: center;        
        
        width: 100%;      
        @media ${deviceWidth.tablet}{
            width: 100%;                   
        }                         
        
        height: calc(100% - 50px);                
        transition: all 0.5s ease-in-out; 
        position: fixed;        
        right: 0;
        top: 50px;
        .menuitems{
            display: flex;
            box-shadow: 0 0 5px ${(props) => props.theme.lightshadowtheme};           
            flex-direction: column;
            align-items: center;
            justify-content: space-around;                        
            height: 60%;            
            width: 40%;
            a{
                display: flex;
                flex-direction: column;
                align-items:center;
                cursor: pointer;
                color: ${(props) => props.theme.white};
                text-decoration: none;                
                &:hover{
                    border-bottom: 2px solid ${(props) => props.theme.goldish};
                    .MuiSvgIcon-root{
                        color: ${(props) => props.theme.lightred}
                    }
                }
            }
        }
    }
    
    footer{                
        min-height: 30px;        
        margin-top: auto;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;        
        font-size: 0.875rem;        
        background-color: ${(props) => props.theme.midDarkBlue};      
        color: ${(props) => props.theme.white};        
    }    
`;

Individual pages get their own style files too. PersonStyle.js, for instance, defines a PersonsListContainer as a styled div. Inside it, flexbox and grid, paired with deviceWidth breakpoints, handle responsive layout for small, large, and very large viewports:

import styled from "styled-components";
import { deviceWidth, colors } from "./definition";

export const PersonsListContainer = styled.div`
  margin: 50px 80px;
  @media ${deviceWidth.tablet} {
    margin: 50px 10px;
  }
  a {
    text-decoration: none;
  }
  .top {
    display: flex;
    justify-content: flex-end;
    padding: 5px;
    .MuiSvgIcon-root {
      cursor: pointer;
      &:hover {
        color: ${colors.darkred};
      }
    }
  }
  .personslist {
    margin-top: 20px;
    display: grid;
    place-items: center;
    grid-template-columns: repeat(5, 1fr);
    @media ${deviceWidth.laptop} {
      grid-template-columns: repeat(4, 1fr);
    }
    @media ${deviceWidth.tablet} {
      grid-template-columns: repeat(3, 1fr);
    }
    @media ${deviceWidth.tablet_md} {
      grid-template-columns: repeat(2, 1fr);
    }
    @media ${deviceWidth.mobile_lg} {
      grid-template-columns: repeat(1, 1fr);
    }
    grid-gap: 30px;
    .person {
      width: 200px;
      position: relative;
      img {
        width: 100%;
      }
      .content {
        position: absolute;
        bottom: 0;
        left: 8px;
        border-right: 2px solid ${colors.goldish};
        border-left: 2px solid ${colors.goldish};
        border-radius: 10px;
        width: 80%;
        margin: 20px auto;
        padding: 8px 10px;
        background-color: ${colors.transparentWhite};
        color: ${colors.darkBlue};
        h2 {
          font-size: 1.2rem;
        }
      }
    }
  }
`;

Importing that container in PersonListPage.js and using it as a wrapper outputs the styled div:

import React from "react";

const PersonsListPage = () => {
  return (
    <PersonsListContainer>
      ...
    </PersonsListContainer>
  );
};
export default PersonsListPage;

Theme Toggling With Redux

Light and dark themes are plain objects in definition.js:

export const theme = {
  light: {
    dark: "#0B0C10",
    darkBlue: "#253858",
    midDarkBlue: "#42526e",
    lightBlue: "#0065ff",
    normal: "#dcdcdd",
    lighter: "#F4F5F7",
    white: "#FFFFFF",
    darkred: "#E85A4F",
    lightred: "#E98074",
    goldish: "#FFC400",
    bodyText: "#0B0C10",
    lightshadowtheme: "rgba(0, 0, 0, 0.1)"
  },
  dark: {
    dark: "white",
    darkBlue: "#06090F",
    midDarkBlue: "#161B22",
    normal: "#dcdcdd",
    lighter: "#06090F",
    white: "white",
    darkred: "#E85A4F",
    lightred: "#E98074",
    goldish: "#FFC400",
    bodyText: "white",
    lightshadowtheme: "rgba(255, 255, 255, 0.9)"
  }
};

The theme logic lives in Redux. globalActions.js imports both themes and dispatches actions whose payloads are the theme objects. The payloads are also written to local storage with consistent keys so the choice survives a page refresh:

import { SET_DARK_THEME, SET_LIGHT_THEME } from "../constants/globalConstants";
import { theme } from "../../styles/definition";

export const switchToLightTheme = () => (dispatch) => {
  dispatch({
    type: SET_LIGHT_THEME,
    payload: theme.light
  });
  localStorage.setItem("theme", JSON.stringify(theme.light));
  localStorage.setItem("light", JSON.stringify(true));
};

export const switchToDarkTheme = () => (dispatch) => {
  dispatch({
    type: SET_DARK_THEME,
    payload: theme.dark
  });
  localStorage.setItem("theme", JSON.stringify(theme.dark));
  localStorage.setItem("light", JSON.stringify(false));
};

The reducer handles theme changes with a straightforward switch on the action type. Beyond returning the theme payload, it keeps a boolean light flag in state so components can tell which mode is active:

import { SET_DARK_THEME, SET_LIGHT_THEME } from "../constants/globalConstants";

export const toggleTheme = (state = {}, action) => {
  switch (action.type) {
    case SET_LIGHT_THEME:
      return {
        theme: action.payload,
        light: true
      };
    case SET_DARK_THEME:
      return {
        theme: action.payload,
        light: false
      };
    default:
      return state;
  }
};

The theme reducer joins the others in the root reducer. In store.js, the initial state pulls the saved theme back out of local storage:

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { theme as initialTheme } from "../styles/definition";
import reducers from "./reducers/index";

const theme = localStorage.getItem("theme")
  ? JSON.parse(localStorage.getItem("theme"))
  : initialTheme.light;

const light = localStorage.getItem("light")
  ? JSON.parse(localStorage.getItem("light"))
  : true;

const initialState = {
  toggleTheme: { light, theme }
};
export default createStore(reducers, initialState, applyMiddleware(thunk));

Applying Themes to Living Components

ThemeProvider from styled-components accepts the theme object and passes it down the tree. App.js selects the active theme from the Redux store and hands it to the provider:

import React from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import { useSelector } from "react-redux";
import { ThemeProvider } from "styled-components";

function App() {
  const { theme } = useSelector((state) => state.toggleTheme);
  let Theme = theme ? theme : {};
  return (
    <ThemeProvider theme={Theme}>
      <Router>
        ...
      </Router>
    </ThemeProvider>
  );
}
export default App;

Any styled component can then reference custom properties like bodyText for color:

color: ${(props) => props.theme.bodyText};

The same pattern extends to other declarations, such as a themed border-bottom:

border-bottom: 2px solid ${(props) => props.theme.goldish};

Wrapping Up

The walkthrough covered the full pipeline: standing up a Sanity.io backend, querying it with GROQ, feeding the results into Redux, and pulling them into React components via react-redux. Styling and dark/light mode came from styled-components, with theme state persisted through Redux and local storage.

The example app is intentionally small, but the same architecture scales to larger projects. The complete source is available in the GitHub repo, and the key references are the Sanity, Redux, and styled-components documentation, the GROQ cheat sheet, and the Material UI docs.