Build a Completely Dynamic UI with Sanity, a Node Server and React

Build a Completely Dynamic UI with Sanity, a Node Server and React

Building with LEGO has always been an interest of mine. Piecing together a 3-dimensional puzzle, either from the restrictions of a manual or within the creative sphere presents a unique blend of mental stimulation and a test of patience.

This creativity in building different structures based on a core is a useful trait in many aspects of life, let alone web development. During this article, we’ll build on this concept to create a completely dynamic UI. Much like assembling LEGO blocks, we’ll piece together sections of content, where you’re in complete control of the final outcome.

The Concept

So the idea is to perform an initial request to retrieve the application routes from Sanity, and dynamically configure a React Router with them. These routes will along with the route name contain a reference to the dynamic page in question, and is used to retrieve the page along with it’s associated sections. Static content like the header and footer exists beyond the outlet, to remain consistent between route transitions.

Building our Sanity instance

Since all the content for our application will be served from Sanity, it’s the logical place to start out. Sanity is a popular solution for serving content to consumer applications in a flexible and dynamic manner. You can follow the instructions on this page on how to set it up.

Once the initial setup is complete, add the following folder structure inside the Sanity application.

|– …
|– schemas
|– documents
|- application.ts
|– dynamicPage
|– sections
|- SectionOne.ts
|- SectionTwo.ts
|– …
|- index.ts
|- dynamicPage.ts
|– objects
|- dynamicRoute.ts

Sections does, as the image above describes, define an isolated section of content on the page. This could be everything from a HeroBanner on the top of the page to a simple heading, and will look like this.

// schemas/dynamicPage/sections/sectionOne.ts
export const sectionOne = defineField({
title: “Section One”,
name: “sectionOne”,
type: “object”,
fields: [
defineField({
title: “Properties”,
name: “properties”,
type: “object”,
fields: [
defineField({
title: “Heading”,
name: “heading”,
type: “string”,
description: “The heading for the banner.”,
}),
]
})
]
});

// schemas/dynamicPage/sections/sectionTwo.ts
export const sectionTwo = defineField({
title: “Section Two”,
name: “sectionTwo”,
type: “object”,
fields: [
defineField({
title: “Properties”,
name: “properties”,
type: “object”,
fields: [
defineField({
title: “Heading”,
name: “heading”,
type: “string”,
description: “The heading for the banner.”,
}),
]
})
]
});

The properties naming will be familiar for those with a React background, as the idea is to pass these values as props to the component in question.

Then we need to create an array field to export these sections. We’ll also add a sort with a localCompare fn to sort them alphabetically.

// schemas/dynamicPage/sections/sections.ts
import {sectionOne} from “./sectionOne”
import {sectionTwo} from “./sectionTwo”
import {defineField} from “sanity”

const sectionsByName= [
sectionOne,
sectionTwo
].sort((a, b) => a.title!.localeCompare(b.title!))

export const sections = () =>
defineField({
title: “Sections”,
name: “sections”,
type: “array”,
of: sectionsByName,
})

A dynamic page is comprised of one or more sections in any particular order, and does also include a title for ease of reference.

// schemas/dynamicPage/dynamicPage.ts
import {sections} from “./sections/sections”

export const dynamicPage = defineField({
title: “Dynamic Pages”,
name: “dynamicPage”,
type: “document”,
fields: [
defineField({
title: “Title”,
name: “title”,
type: “string”
}),
sections()
]
})

The dynamic route will contain all our application routes with references to the dynamic page(s), and lives within the application document, which outlies the structure of the entire application.

// objects/dynamicRoute
export const dynamicRoute = defineField({
title: “Dynamic Route”,
name: “dynamicRoute”,
type: “object”,
fields: [
defineField({
title: “Path”,
name: “path”,
type: “string”,
description:
“The path this route should match. Supports all route expressions that expressjs supports.”,
validation: (Rule) =>
Rule.required().custom((path: string | undefined) =>
(path || “”).startsWith(“/”) ? true : “Path must start with /”
),
}),
defineField({
title: “Properties”,
name: “properties”,
type: “object”,
fields: [
defineField({
title: “Reference page”,
name: “page”,
type: “reference”,
to: [{type: “dynamicPage”}],
})
]
}),
]
});
// schemas/documents/application.ts
export const application = defineField({
name: “application”,
type: “document”,
description: “Describes the central configuration for an application”,
fields: [
defineField({
title: “Application name”,
name: “name”,
type: “string”,
validation: (Rule) => Rule.required()
}),
defineField({
title: “Description”,
name: “description”,
type: “string”,
fieldset: “info”,
description: “A short description of this application.”,
}),
defineField({
title: “Routes”,
name: “routes”,
type: “array”,
of: [dynamicRoute],
options: {
layout: “list”,
},
})
]
});

Finally, we need to register our documents among the schemaTypes.

// schemaTypes/index.ts
import { application } from “../schemas/documents/application”;
import { dynamicPage } from “../schemas/dynamicPage/dynamicPage”;

export const schemaTypes = [application, dynamicPage]

Adding Content

Having the CMS instance up and running is as well and good, but we still need to add some content.

Start with adding a dynamic page document with two sections — SectionOne and SectionTwo.

Then we need to create an application and add a route with a reference to the dynamic page.

Finally, publish the changes.

Node Server with GROQ Queries

Now that our Sanity instance is finished, we need to consume the content. Here, we’ll use a Node Server with a Sanity client to perform GROQ queries towards the instance. Then we’ll expose the content to the client through endpoints created with the Express framework.

Start with initializing a new Node application and install the following packages.

npm init -y

npm install express @sanity/client cors

Since we want to use TypeScript here as well, we need to configure it within the application through the following command, along with installing the necessary types.

npx tsc –init

npm i -D typescript ts-node @types/node @types/express @types/cors

Continue with building up the following structure on the server.

|– queries
| |- SanityDynamicRoutesQuery.ts
| |- SanityDynamicPageQuery.ts
|- start.ts

The start.ts file will be the entry point of the server and contain our endpoints along with CORS configurations for the client-origin.

In order to create workable Sanity queries, we need some additional configurations, in order to establish a connection towards our instance. This is achieved by adding the desired configurations inside the createClient method, and is all available in Sanity.

// start.ts
import {createClient} from ‘@sanity/client’
import { SanityDynamicPageQuery } from ‘./queries/SanityDynamicPageQuery’
import { SanityDynamicRoutesQuery } from ‘./queries/SanityDynamicRoutesQuery’
import { Request, Response } from ‘express’
import cors from “cors”;

export const client = createClient({
projectId: ‘your-project-id’,
dataset: ‘your-dataset’,
useCdn: false, // set to `false` to bypass the edge cache
apiVersion: ‘2024-02-29’, // use current date (YYYY-MM-DD) to target the latest API version
})

var express = require(‘express’)
var app = express()

const allowedOrigins = [‘https://localhost:5173’]; // standard Vite port

const options: cors.CorsOptions = {
origin: allowedOrigins
};

app.use(cors(options));

app.get(‘/api/v1/dynamicRoutes’, async function (req: Request, res: Response) {
var dynamicRoutes = await SanityDynamicRoutesQuery();

return res.send(dynamicRoutes);
})

app.get(‘/api/v1/dynamicPage/:id’, async function (req: Request, res: Response) {
var dynamicPage = await SanityDynamicPageQuery(req.params.id)

return res.send(dynamicPage);
})

app.listen(3000, () => {
console.log(`[server]: Server is running at http://localhost:3000`);
})

Starting with the SanityDynamicRouteQuery, we need to perform a GROQ query to retrieve the data from Sanity. You can experiment with these queries yourself using the Sanity Vision Plugin in the CMS instance.

The query below will return all published dynamic routes of the application in question, excluding draft documents.

// queries/SanityDynamicRoutesQuery.ts
export const SanityDynamicRoutesQuery = async () => {
const groqQuery = `
*[!(_id in path(‘drafts.**’)) && _type == “application”][0]{routes}`;

const data = await client.fetch(groqQuery);

return data
}

Then add the SanityDynamicPageQuery file. This will query the dynamic page using the reference id from the output in the SanityDynamicRoutesQuery, and return a list sections with it’s associated properties.

// queries/SanityDynamicPageQuery.ts
export const SanityDynamicPageQuery = async (id: string) => {
const groqQuery = `*[
_type == “dynamicPage”
&& (_id == ‘${id}’)]{
id{current},
sections{
_type,
properties,
},

}[0]`

const data = await client.fetch(groqQuery);

return data;
}

Start the server and call both of these endpoints with Postman or a similar tool to verify that everything works as intended.

npx ts-node start.ts

[server]: Server is running at http://localhost:3000

Building The Dynamic UI

Starting with the recent stable version of Node, we’ll use the following command to initialize our application with Vite, React and TypesScript.

npm create vite@latest

> React
> TypeScript

Once the command is finished, verify everything works by initializing the application.

npm run dev

This will initialize a local dev-server on http. In order to handle local certificates with Vite, use the vite-plugin-mkcert with the following configuration options.

We also utilize this opportunity to install the react-router-dom package.

npm i react-router-dom
npm i -D vite-plugin-mkcert
// vite.config.ts
import {defineConfig} from’vite’
import mkcert from ‘vite-plugin-mkcert’

export default defineConfig({
plugins: [react(), mkcert()]
server: {
https: true
},
})

The bare bone application should now be up and running on https after initialization, and we can start adding the following structure.

|– features
|– DynamicPage
|- DynamicPageWrapper.tsx
|- DynamicPage.tsx
|- SectionErrorBoundary.tsx
|– Sections
|– SectionOne
|- SectionOne.tsx
|- SectionOne.module.scss
|- index.ts
|– SectionTwo
|- SectionTwo.tsx
|- SectionTwo.module.scss
|- index.ts
|– Layout
|- MainContainer.tsx
|- Footer.tsx
|- Header.tsx
|- NotFound.tsx
|- index.ts
|– routes
|- routesConfig.ts
|– interfaces
|- IDynamicSection.ts
|- App.tsx

Starting with the interfaces, we need to add several of them to handle the dynamic content. The DynamicSectionFinder is an interface for a mapper function, and will be explain in further details later on. The IDynamicRoute is the interface we’ll use for the dynamic routes, and includes a path and properties. The IDynamicSectionProps, IDynamicSection and IDynamicSectionValues are all interfaces related to sections within a dynamic page, and will be made clearer as we move on.

// interfaces/IDynamicSectionFinder
export type DynamicSectionFinder =
<T>(name: string) => IDynamicSectionValues<T> | undefined;

// interfaces/IDynamicRoute
export interface IDynamicRoute<Properties = any> {
path: string;
properties: Properties;
}

// interfaces/IDynamicSection
export interface IDynamicSectionProps<T> {
properties: T;
}

// interfaces/IDynamicSection
export interface IDynamicSection<Properties> {
name: string;
displayComponent: React.ComponentType<IDynamicSectionProps<Properties>>;
}

// interfaces/IDynamicSectionValues
export interface IDynamicSectionValues<Properties = any> {
_type: string;
properties: Properties;
}

Moving on to the routesConfig.ts file, add the following content inside.

import {NotFound} from “../features/NotFound/NotFound.tsx”

export const routesConfig = [
{
path: “*”,
element: <NotFound />
},
]

Yes, only one route for now, as the other ones will be added dynamically.

Then we move over to the deepest level of our dynamic structure, the sections. Starting with SectionOne– and Two, add the following content.

// features/sections/SectionOne/SectionOne.tsx
import { IDynamicSectionProps } from “../../../interfaces/IDynamicSection”;

export interface ISectionOne {
heading: string;
}

export const SectionOne = ({
properties,
}: IDynamicSectionProps<ISectionOne>) => {
const { heading } = properties;
return <h1>{heading}</h1>;
};

// features/sections/SectionTwo/SectionTwo.tsx
import { IDynamicSectionProps } from “../../../interfaces/IDynamicSection”;

export interface ISectionTwo {
heading: string;
}

export const SectionTwo = ({
properties,
}: IDynamicSectionProps<ISectionTwo>) => {
const { heading } = properties;
return <h1>{heading}</h1>;
};

Now that we’ve added our sections, we’ll move over to the index file of each section.

// features/Sections/SectionOne/index.ts
import { IDynamicSection } from “../../../interfaces/IDynamicSection”
import {SectionOne, ISectionOne} from “./SectionOne”

const sectionOne: IDynamicSection<ISectionOne> = {
name: “sectionOne”,
displayComponent: SectionOne
}

export default sectionOne

// features/Sections/SectionTwo/index.ts
import { IDynamicSection } from “../../../interfaces/IDynamicSection”
import {SectionTwo, ISectionTwo} from “./SectionTwo”

const sectionTwo: IDynamicSection<ISectionTwo> = {
name: “sectionTwo”,
displayComponent: SectionTwo
}

export default sectionTwo

You can see that we’ve defined a name for the section along with the displayComponent property which are referencing the component.

Then we need to create a mapper function, to be able to retrieve the section depending on the name.

// features/Sections/index.ts
import {IDynamicSection} from “../../interfaces/IDynamicSection”

import sectionOne from “./SectionOne”
import sectionTwo from “./SectionTwo”

export const appSections: {[key: string]: IDynamicSection<any>} = {
– [sectionOne.name]: sectionOne,
– [sectionTwo.name]: sectionTwo
}

export const findSectionByName = (name: string) => appSections[name];

Then we move on to the DynamicPage component, responsible of rendering each section.

// features/DynamicPage/DynamicPage.tsx
import React from “react”;
import { useLocation, useNavigate, useParams } from “react-router-dom”;
import { IDynamicSectionValues } from “../../interfaces/IDynamicSection”;
import { IDynamicPage } from “../../interfaces/IDynamicPage”;
import { SectionErrorBoundary } from “./SectionErrorBoundary”;
import { findSectionByName } from “../Sections”;

interface RenderSectionErrorProps {
title: string;
message: string;
section: IDynamicSectionValues;
}

const RenderSectionError = (props: RenderSectionErrorProps) => (
<div>
<h2>{props.title}</h2>
<p>{props.message}</p>
<h3>Section</h3>
<pre>
<code>{JSON.stringify(props.section, null, 2)}</code>
</pre>
</div>
);

export interface IDynamicPageProps {
dynamicPage: IDynamicPage;
showHandlerNotFoundError?: boolean;
showGenericSectionErrors?: boolean;
}

export const DynamicPage = (props: IDynamicPageProps) => {
const { showHandlerNotFoundError = true, showGenericSectionErrors = true } =
props;

const navigate = useNavigate();
const location = useLocation();
const params = useParams();

const RenderSection = (section: IDynamicSectionValues) => {
try {
const sectionHandler = findSectionByName(section._type);

if (!sectionHandler) {
console.error(“SectionHandler not found for”, section._type);
return showHandlerNotFoundError ? (
<RenderSectionError
title=”Not found”
message=”The section was not found”
section={section}
/>
) : null;
}

const sectionProps = {
navigate,
location,
params,
properties: section.properties || {},
};

const SectionHandler =
sectionHandler.displayComponent as React.ElementType;

return (
<SectionErrorBoundary>
<SectionHandler {…sectionProps} />
</SectionErrorBoundary>
);
} catch (error) {
console.error(“Error rendering DynamicPage section”, section, error);

return showGenericSectionErrors && error instanceof Error ? (
<RenderSectionError
section={section}
title=”Render error (check console)”
message={error.message}
/>
) : null;
}
};

return (
<article data-page-id={props.dynamicPage.id}>
{(props.dynamicPage.sections || []).map((section, idx) => (
<React.Fragment key={idx}>
<RenderSection {…section} />
</React.Fragment>
))}
</article>
);
};

We start out by adding the RenderSectionError component, which is a component we’ll display when there’s a problem rendering a section. This component will contain some information about which section has trouble rendering, along with the associated JSON output for debugging purposes.

So the DynamicPage component have a component within – RenderSection. The RenderSection component is, as the name states, responsible of rendering a section, and accepts a section-data retrieved from Sanity. Then, within a try-catch block, we use the sectionFinder mapping fn to find the specific section.

In addition to the props we forward for each section, we include some additional meta information for each page. The displayComponent is then retrieved from the sectionHandler and returned.

One level above the DynamicPage we’ve now created is the DynamicPageWrapper.

// features/DynamicPage/DynamicPageWrapper.tsx
import { useEffect, useState } from “react”;
import { IDynamicRoute } from “../../interfaces/IDynamicRoute”;
import { DynamicPage, IDynamicPageProps } from “./DynamicPage”;
import { NotFound } from “../Layout/NotFound”;

interface IDynamicPageWrapperProps {
route: IDynamicRoute;
}
const DynamicPageWrapper = (props: IDynamicPageWrapperProps) => {
const pageId: string = props.route.properties.page._ref;
const [dynamicPage, setDynamicPage] = useState(null);
const [isLoading, setIsLoading] = useState(true);

async function fetchDynamicPage(id: string) {
return fetch(`http://localhost:3000/api/v1/dynamicPage/${id}`)
.then((response) => response.json())
.then((data) => {
setDynamicPage(data);
})
.finally(() => setIsLoading(false));
}

useEffect(() => {
if (!dynamicPage) {
fetchDynamicPage(pageId);
} else {
setIsLoading(false);
}
}, []);

const RenderPage = () => {
if (isLoading) {
return <p>Loading…</p>;
}

if (!dynamicPage) {
return <NotFound />;
}

const newProps: IDynamicPageProps = {
dynamicPage,
};

return <DynamicPage {…newProps} />;
};

return <RenderPage />;
};

export default DynamicPageWrapper;

This component has a reference to the page being rendered, which is used to retrieve it’s content through a fetch request. The result of this request along with the sectionFinder fn is then passed down to the DynamicPage as props.

The content of the Layout folder will act as the static wrapper of our dynamic content.

// features/Layout/Footer.tsx
export const Footer = () => {
return <footer>Static Footer</footer>
}

// features/Layout/Header.tsx
export const Header = () => {
return <header>Static Header</header>
}

// features/Layout/MainContainer.tsx
export const MainContainer = () => {
return (
<>
<Header />
<Outlet />
<Footer />
</>
)
}

And to finish it all up is the App component.

// App.tsx
import { useEffect, useState } from “react”;
import “./App.css”;
import { IDynamicRoute } from “./interfaces/IDynamicRoute”;
import DynamicPageWrapper from “./features/DynamicPage/DynamicPageWrapper”;
import { RouterProvider, createBrowserRouter } from “react-router-dom”;
import { MainContainer } from “./features/Layout/MainContainer”;
import { routesConfig } from “./routes/routesConfig”;

interface IRouteHandlerProps {
route: IDynamicRoute;
}

async function fetchRoutes() {
return fetch(“http://localhost:3000/api/v1/dynamicRoutes”)
.then((response) => response.json())
.then((data) => data)
.catch((error) => console.error(“Error:”, error));
}

function App() {
const [routes, setRoutes] = useState([]);

const renderRoute =
(route: IDynamicRoute): React.ElementType =>
(routeProps: IRouteHandlerProps) => {
return <DynamicPageWrapper {…routeProps} route={route} />;
};

const RenderDynamicRoutes = routes.map((route: IDynamicRoute) => {
const currentRoute = route.path.toString();
const RenderedRoute = renderRoute(route);

return {
path: currentRoute,
element: <RenderedRoute />,
};
});

useEffect(() => {
const fetchData = async () => {
const data = await fetchRoutes();
setRoutes(data.routes);
};

fetchData();
}, []);

return (
<RouterProvider
router={createBrowserRouter([
{
path: “/”,
element: <MainContainer />,
children: […RenderDynamicRoutes, …routesConfig],
errorElement: <div>ERROR</div>,
},
])}
fallbackElement={
<div>
<span>Loading…</span>
</div>
}
/>
);
}

export default App;

So, we retrieve the routes through a fetch request, which is then added to the component state. Then we have the RouteProvider from React Router which uses the createBrowserRouter method to construct our dynamic routes with the RenderDynamicRoutes mapper.

My name is Mathis Garberg, and I am a consultant at Dfind Consulting in Oslo, Norway.

Please follow and like us:
Pin Share