Remix is a full-stack web framework that uses the React library. It allows you to focus on the user interface and web fundamentals to deliver a fast, slick, and resilient user experience.
Everything server side
Contrary to Next.js or SvelteKit where you have the options to generate your pages statically or server side, remix is always server side but with a twist. Remix is a seamless server and browser runtime tool that provides snappy page loads and instant transitions by leveraging distributed systems and native browser features.
So let's get started . . .
Create a Remix Run project
The first thing you need to do is to create your remix run project, and we'll use npx to do this.
Open a terminal and run
npx create-remix@latest
Next you will be asked to enter the name of the folder you would like to create for your project.
Next select where you would like to deploy it, for this for this example let's select: "Remix App Server"
Then you will be asked if you like to use Typescript or JavaScript, and we'll select Typescript for this tutorial
Finally, you'll be asked if you want it to run npm install, just hit enter to say "YES"
After Remix generates your project you can now open the folder in your favorite code editor or IDE. Take a look around and see what files were created.
Running remix
Let's see what remix gives us by default, in your terminal run
npm run dev
Then open in your browser http://localhost:3000 and you should see:
This is the default remix project, it has a lot of information about the framework that is worth looking through.
File structure
Like any other modern Typescript project, in the root we have all the configuration files such as package.json, tsconfig.json and the remix ones as well remix.config.js, but for this tutorial we'll not be touching these.
Let's focus on the app folder.
app/entry.client.tsx as the entry point for the browser bundle. This module gives you full control over the "hydrate" step after JavaScript loads into the document.
app/entry.server.tsx to generate the HTTP response when rendering on the server.
app/root.tsx is your root layout, all the pages will "inherit" the layout and the HTML from this file.
Finally, we have the folder routes where we'll create our pages and styles.
Get and store dotCMS auth token
To request a page from dotCMS we need to get an auth token. For security reasons we'll need to store that token in an environmental variable.
In the terminal run
npm add dotenv
touch .env
With this, we will add the dotenv package and create a .env file.
To get the dotcms token, run:
curl -H "Content-Type:application/json" -X POST -d '
{ "user":"admin@dotcms.com", "password":"admin", "expirationDays": 10 }
' https://demo.dotcms.com/api/v1/authentication/api-token
From the response, copy the token
Open your .env file and add the variable
DOTCMS_API_KEY='YOUR TOKEN HERE'
Finally, we need to tell remix to run the server using this env variables. Open your package.json and replace the "dev" script with:
"dev": "node -r dotenv/config node_modules/.bin/remix dev",
Cleaning up root.tsx
The root.tsx file by default has styles and layout information that we will not use, so replace all with the following:
import { LiveReload, Meta, Outlet, Links } from "remix";
function Document({
children,
title = 'DotCMS Demo'
}: {
children: React.ReactNode;
title?: string;
}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>{title}
<Meta />
<Links />
</head>
<body>
{children}
{process.env.NODE_ENV === "development" ? (
) : null}
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
After the cleanup, we end up with two react components, Document and App.
Document acts like the global layout for all the pages, and it contains all the base HTML. It also includes 3 components from remix:
<Meta />: This will render all the meta tags that we add in each page
<Links />: Renders all the links from each page, for example the CSS
<LiveReload />: Is only added in development mode and as the name indicates will handle the live reload of the browser when something changes.
App component is the main component that will render for all the pages and that's why we add the <Outlet /> component that will act like a placeholder to render the content for each route.
One route to run them all
dotCMS has a powerful routing system, and it provides the best UI for content creators and marketers. It not only allows them to create content but also pages and routes. Because of this, we don't need to handle all the routes in remix but instead we'll let dotCMS handle this.
In remix each file inside the routes folder will generate a new route, for example /routes/blog.tsx will create /blog route in your app.
Since we want to remix to catch all routes, we'll need to create a new file named $.tsx in the routes folder and remove all other files. The $ in the file name acts like a wildcard to catch all routes.
Specifying the file for root path
Since $.tsx will not catch "/" we need to create a index.tsx file and add the following code:
export * from './$'
export { default } from './$';
By doing this, we will be using the same code for both routes and keeping all the page render in one file.
Get and render the dotcms page
Open $.tsx and the following code:
import { useLoaderData, json, LoaderFunction } from "remix";
type IndexData = {
description: string;
title: string;
};
export let loader: LoaderFunction = async ({ params }) => {
const res = await fetch('https://demo.dotcms.com/api/v1/page/render/${params['*'] || 'index'}', {
"headers": {
"Authorization": 'Bearer ${process.env.DOTCMS_API_KEY}'
},
"method": "GET",
});
const data = await res.json();
const page = data.entity.page;
return json({
title: page?.seoTitle || page?.title,
description: page?.seodescription || page?.description
});
};
export function meta({
data
}: {
data: IndexData | undefined;
}) {
return {
title: data?.title || data?.title,
description: data?.description
};
}
export default function Index() {
let data = useLoaderData<IndexData>();
return (
<main>
<h2>{data.title}</h2>
<p>{data.description}</p>
</main>
);
}
Let's walk through this code.
First, we created a type named IndexData for the data of the page from dotCMS. For now we will use just two properties, title and description, but this will grow.
Then with the loader function, which provides data to components and is only called on the server, we can connect to a database or run any server-side code we want right next to the component that renders it. In our case, we will fetch the data from dotCMS.
With the dotCMS page API we receive the url of the page, and obtain this information from the parameters in the loader function inside the "params" property like this params['*'].
When users hits "/" params['*'] will be empty. For this, we will be using a fallback "index" to pass to dotCMS api.
For example, if you hit localhost:3000/about-us the params['*'] will be "about-us" and that will be the url we used to pass to the dotCMS API in the fetch url.
To return the data to the component we use the function json which is a shortcut for creating application/json native Fetch Response. It assumes you are using utf-8 encoding.
Adding meta information
All pages need a title and description for better SEO. To do that in remix, you export a function named "meta" that also receives as a parameter the data coming from the loader. This function returns an object with title and description for this tutorial, but you can do much more. Here is some additional information: meta docs.
Rendering the page
Finally, we need to render the page by exporting a functional react component, in this case "Index". To get the data, we need to use the hook provided by remix called: useLoaderData. This hook returns the JSON parsed data from your route loader function.
With the data stored in a variable, you can render the title and the description in a h2 and p respectively.
And that's how using one remix route file we can render a full DotCMS page.
Now if we run the server and point our browser to http://localhost:3000/index we should see:
This is the index page of https://demo.dotcms.com/, but we are only showing the title and description. For the next step, let's render all the content of the page.
Installing and configuring Tailwind CSS
To style our page and make it a wonderful design, we are going to use Tailwind CSS. Tailwind CSS is a design system implementation in pure CSS. It is also configurable and gives developers superpowers.
In your terminal, run
npm add -D concurrently tailwindcss @tailwindcss/typography
This is installed concurrently to run multiple commands. Tailwindcss and @tailwindcss/typography is a plugin to add beautiful typographic defaults to any vanilla HTML.
Next we'll need to run:
npx tailwindcss init
To create the tailwind.config.js file edit the file and add all this code:
module.exports = {
mode: "jit",
purge: ["./app/**/*.{ts,tsx}"],
safelist: ['col-start-1',
'col-start-2',
'col-start-3',
'col-start-4',
'col-start-5',
'col-start-6',
'col-start-7',
'col-start-8',
'col-start-9',
'col-start-10',
'col-start-11',
'col-start-12',
'col-start-13',
'col-start-auto',
'col-end-1',
'col-end-2',
'col-end-3',
'col-end-4',
'col-end-5',
'col-end-6',
'col-end-7',
'col-end-8',
'col-end-9',
'col-end-10',
'col-end-11',
'col-end-12',
'col-end-13',
'col-end-auto'],
darkMode: "media", // or 'media' or 'class'
theme: {
extend: {}
},
variants: {},
plugins: [
require('@tailwindcss/typography')
]
};
The purge property configures the folders of our pages and components, which will contain the class names to be included in the final CSS build, so it doesn't include unused Tailwind classes.
safelist contains the classes that we want to include always - these are the grid classes. We need to do this because we will generate the grid class dynamically and Tailwind can't include them automatically.
plugins include the typography plugin.
Building Tailwind
Open your package.json file and add a new script in the scripts property
"dev:css": "tailwindcss -o ./app/tailwind.css --watch",
We will also need to update the dev script to build Tailwind before running the dev server so the styles are available in the pages.
"dev": "concurrently \"npm run dev:css\" \"node -r dotenv/config node_modules/.bin/remix dev\"",
Create and add CSS file
Now it's time to add a CSS file inside the "styles'' folder. We'll do this by removing all the files and adding "tailwind.css" with the following code:
@tailwind base;
@tailwind components;
@tailwind utilities;
Next we'll open the root.tsx and add the following code before the Document component
import styles from "./tailwind.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
To add the styles to a page in remix we will need to export a "links" function that returns a collection and each item of that will create a <link> tag in the page. In this case, we're doing a link to add the stylesheet to all the pages because, remember, we're doing this in the root.tsx.
This isn't required, but it's recommended to add the generated CSS file to our .gitignore list.
Adding classes for default typography
The Tailwind typography plugins require that you add "prose" class in the HTML, so open the $.tsx and add the following classes: "prose lg:prose-xl m-auto" to the main tag. So, the Index function component should end up like this:
export default function Index() {
let data = useLoaderData();
return (
<div className="remix__page">
<main className="prose lg:prose-xl m-auto">
<h2>{data.title}</h2>
<p>{data.description}</p>
</main>
</div>
);
}
Open your browser in http://localhost:3000/index and, you should see the tailwind CSS has been loaded:
Rendering the layout and content
First, let's understand what information is in the layout object of a dotCMS page.
{
"layout": {
"width": null,
"title": "TITLE",
"header": true,
"footer": true,
"body": {
"rows": [
{
"columns": [
{
"containers": [
{ "identifier": "IDENTIFIER", "uuid": "UUID" }
],
"widthPercent": 100,
"leftOffset": 1,
"styleClass": "someclass",
"width": 12,
}
],
}
]
}
}
}
There are 12 columns in the page grid. What we care about when building the page are the following properties:
header and footer booleans that indicate show/hide
body includes the rows and columns information with an array ready to iterate and use components
columns each element represents a box
containers is an array of references to the containers containing the column
the widthPercent the width of the column in percent
leftOffset the position within the grid where this column box starts
styleClass class added by the content creator
width the width of the column box
With this information, we can create components with Tailwind classes.
Row component
Create /components/Row.js and add the following code:
type Props = {
children: React.ReactNode
};
export function Row({ children }: Props): JSX.Element {
return <section className="grid grid-cols-12 gap-8 my-2">{children}</section>
}
This component will be used to render each row of the page layout, the grid containers of 12 columns, the vertical margin and the grid gap.
Inside this component, we are going to render a series of <Column /> components that we will create next.
Column component
Create /components/Column.js and add the following code:
type Props = {
children: React.ReactNode;
leftOffset: number;
width: number;
};
export function Column({ children, leftOffset, width }: Props): JSX.Element {
const end = leftOffset + width;
return <div className={'col-start-${leftOffset} col-end-${end}'}>{children}</div>;
}
In this case, let's assign where each box of the layout starts and ends in the component. Tailwind provides the col-start-N and col-end-N classes, and we have that information in the dotCMS layout object information.
Using <Row /> and <Column />
We need to do a few things in the $.tsx file, let me show you how:
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index e881c39..f7952df 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -1,9 +1,13 @@
import type { LoaderFunction } from "remix";
import { useLoaderData, json } from "remix";
+import { Column } from "~/components/Column";
+import { Row } from "~/components/Row";
+import { Layout } from "~/models/Layout.model";
type IndexData = {
description: string;
title: string;
+ layout: Layout;
};
export let loader: LoaderFunction = async ({ params }) => {
@@ -13,12 +17,12 @@ export let loader: LoaderFunction = async ({ params }) => {
},
"method": "GET",
});
- const data = await res.json();
- const page = data.entity.page;
+ const { entity } = await res.json();
return json({
- title: page?.seoTitle || page?.title,
- description: page?.seodescription || page?.description
+ title: entity.page?.seoTitle || entity.page?.title,
+ description: entity.page?.seodescription || entity.page?.description,
+ layout: entity.layout
});
};
@@ -36,13 +40,25 @@ export function meta({
export default function Index() {
let data = useLoaderData();
+ const { layout } = data;
+ const { body: { rows } } = layout;
return (
<div className="remix__page">
<main className="prose lg:prose-xl m-auto">
<h2>{data.title}</h2>
<p>{data.description}</p>
+
+ {rows.map(({ columns }, i) => (
+ <Row key={'row-${i}'}>
+ {columns.map(({ leftOffset, width }, k) => (
+ <Column leftOffset={leftOffset} width={width} key={'col-${k}'}>
+ Column {k}
+ </Column>
+ ))}
+ </Row>
+ ))}
</main>
</div>
);
-}
+}
There are many changes here, but let me explain what we did:
Import Row and Column component
Updated the loader function to return the layout information
Updated the Index component to render a Row component for each row in the layout and a Column component for each column in the row.
Now let's open the browser in http://localhost:3000/index and we should see:
Adding the content
Now that we have the layout, we need to add the content to the page. The content lives in the containers and in the layout object inside columns we have a reference to these containers.
The containers and the content they contain are in the containers property. This object contains detailed information about all the content and containers.
For the purposes of this post, we are going to focus on the contentlets property, which is the object for each piece of content that is created and added to the page:
"containers": {
"ID_OF_CONTAINER": {
"contentlets": {
"uuid-N": [
{
"contentType": "ContentTypeName",
"field1": "Field 1 Content",
"fieldN": "Field N Content"
}
]
}
}
}
The most important property for creating components is contentType because based on this property we will be able to create different React components for each Content Type.
For example, if you have a Content Type of type "Event" you are going to create a React Component to render "Event". The rest of the properties will be the props of the component.
The <Contentlet /> component
For the mapping between the Content Type and its components, we create the component components/Contentlet.js.
import { ContentletModel } from "~/models/Contentlet.model";
import { Activity } from "~/components/Activity";
import { Banner } from "~/components/Banner";
const Components: { [key: string]: Function } = {
Activity,
Banner
}
function Fallback(): JSX.Element | null {
return null;
}
export function Contentlet(contentlet: ContentletModel): JSX.Element {
const Component = Components[contentlet.contentType] || Fallback;
return <Component {...contentlet} />
}
type Props = {
children: React.ReactNode;
leftOffset: number;
width: number;
};
export function Column({ children, leftOffset, width }: Props): JSX.Element {
const end = leftOffset + width;
return <div className={'col-start-${leftOffset} col-end-${end}'}>{children}</div>;
}
This component will be an intermediary that returns the associated component content type of the contentlet we pass in. If the contentlet does not exist, it will return the Fallback component:
Create a component map Components
Make a component to use as default in case we do not have the Fallback component created.
In the Contentlet component, we look for the component in the map using the contentType property. This is where we have to match the Content Type in dotCMS and the component in Next.js and if we don't have a matching Content Type, we return the Fallback component.
Finally, we return the "found" component or the Fallback and the contentlet object is passed in as props.
Using <Contentlet />
Inside the <Column /> component, we iterate and render the containers and then iterate again to find the contentles inside the containers. Let's see the code:
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index f7952df..656d669 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -1,13 +1,16 @@
import type { LoaderFunction } from "remix";
import { useLoaderData, json } from "remix";
import { Column } from "~/components/Column";
+import { Contentlet } from "~/components/Contentlet";
import { Row } from "~/components/Row";
import { Layout } from "~/models/Layout.model";
+import { ContentletModel } from "~/models/Contentlet.model";
type IndexData = {
description: string;
title: string;
layout: Layout;
+ containers: any;
};
export let loader: LoaderFunction = async ({ params }) => {
@@ -22,7 +25,8 @@ export let loader: LoaderFunction = async ({ params }) => {
return json({
title: entity.page?.seoTitle || entity.page?.title,
description: entity.page?.seodescription || entity.page?.description,
- layout: entity.layout
+ layout: entity.layout,
+ containers: entity.containers
});
};
@@ -38,27 +42,38 @@ export function meta({
};
}
export default function Index() {
let data = useLoaderData();
- const { layout } = data;
+ const { layout, containers } = data;
const { body: { rows } } = layout;
+ const containersData = containers;
return (
- <div className="remix__page">
- <main className="prose lg:prose-xl m-auto">
- <h2>{data.title}</h2>
- <p>{data.description}</p>
+ <main className="prose lg:prose-xl m-auto max-w-7xl">
+ <h2>{data.title}</h2>
+ <p>{data.description}</p>
- {rows.map(({ columns }, i) => (
- <Row key={'row-${i}'}>
- {columns.map(({ leftOffset, width }, k) => (
- <Column leftOffset={leftOffset} width={width} key={'col-${k}'}>
- Column {k}
- </Column>
- ))}
- </Row>
- ))}
- </main>
- </div>
+ {rows.map(({ columns }, i) => (
+ <Row key={'row-${i}'}>
+ {columns.map(({ leftOffset, width, containers }, k) => (
+ <Column leftOffset={leftOffset} width={width} key={'col-${k}'}>
+ {containers.map(({ identifier, uuid }, l) => {
+ const contentlets: ContentletModel[] =
+ containersData[identifier].contentlets['uuid-${uuid}'];
+
+ return (
+ <div key={'container-${l}'}>
+ {contentlets.map((contentlet, m) => {
+ return <Contentlet {...contentlet} key={'contentlet-${m}'} />;
+ })}
+ </div>
+ );
+ })}
+ </Column>
+ ))}
+ </Row>
+ ))}
+ </main>
);
}
Let's review this change
Import the Contentlet component
Update the loader function to return the containers information
In the Index function component inside each column, we locate the contentlet and pass it to the Contentlet component. To locate the contentlets we use the identifier and the uuid returning an array of contentlets that are rendered with the Contentlet component.
The code
You can see all the code here: https://github.com/dotCMS/dotcms-javascript-examples/tree/master/remix
Conclusion
Create the remix project
Add the $.tsx route to catch all routes
Add Tailwind CSS
Query the dotCMS APIs to get information from the page.
With the page object we can use Tailwind grids to create the layout.
Make a React component map for each type of content on the page.
The combination of Remix Run with the powerful APIs of dotCMS allows us to create pages quickly and supplement them with a grid system and CSS utilities of Tailwind. This gives us a powerful combination that allows us to take our web project to the next level.