Dynamic CSS with Remix Resource Routes

ebey_jacob/November 25th, 2021

Remix has a very simple API. The power of simplicity can be a hard thing to grasp, but I hope to help you grasp how making fundamentals accessible again leads to elegant solutions to once hard problems.

Think about how you would go about allowing for a user provided custom color palette.

Until recently my solution to this would have been to immediately reach for tailwind and their easy build config and class based approach, but this wouldn't fully solve the sought after goal. That would leave me at a CSS in JS library for their sweet sweet runtime.

With Remix we can solve this without additional client runtime or flash of unstyled content through the use of a good ol' fashion `<link>` and a Remix Resource Route.

TLDR;

You can view the source code for this post at https://github.com/jacob-ebey/dynamic-css-with-remix-resource-routes and a live example at https://dynamic-css-with-remix-resource-routes.vercel.app/demos/theme

After running `npx create-remix@1.0.5` you will notice in the `app/styles/dark.css` we have a few CSS Custom Properties.

We are going to use a session to store the custom theme preferences. Let's make a new file at `app/session.server.ts` and add the following content:

import { createCookieSessionStorage } from "remix";
if (!process.env.COOKIE_SECRET) {
throw new Error("process.env.COOKIE_SECRET is required");
}
// - https://remix.run/api/remix#createcookiesessionstorage
let { commitSession, destroySession, getSession } = createCookieSessionStorage({
cookie: {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
secrets: [process.env.COOKIE_SECRET],
},
});
export { commitSession, destroySession, getSession };

You can read more about the session API Remix provides here: https://remix.run/api/remix#sessions.

We will also create the `Theme` type and some defaults in `app/themes.ts`:

export type Theme = {
foreground: string;
background: string;
links: string;
["links-hover"]: string;
border: string;
};
export let darkTheme: Theme = {
background: "#000000",
border: "#404040",
foreground: "#ffffff",
links: "#75b3ff",
"links-hover": "#99c7ff",
};
export let defaultTheme: Theme = {
background: "#ffffff",
border: "#d1d1d1",
foreground: "#121212",
links: "#0a78ff",
"links-hover": "#0063db",
};

Next we will create a route for modifying your theme preferences at `app/routes/demos/theme.tsx` with the following content:

import type { ActionFunction, LoaderFunction } from "remix";
import { Form, json, redirect, useActionData, useLoaderData } from "remix";
import { commitSession, getSession } from "~/session.server";
import { darkTheme, defaultTheme } from "~/themes";
import type { Theme } from "~/themes";
export function meta() {
return { title: "Theme Demo" };
}
// Loaders provide data to components and are only ever called on the server, so
// you can connect to a database or run any server side code you want right next
// to the component that renders it.
// https://remix.run/api/conventions#loader
export let loader: LoaderFunction = async ({ request }) => {
let session = await getSession(request.headers.get("Cookie"));
let theme = session.get("theme");
return theme || defaultTheme;
};
// When your form sends a POST, the action is called on the server.
// - https://remix.run/api/conventions#action
// - https://remix.run/guides/data-updates
export let action: ActionFunction = async ({ request }) => {
let [session, formData] = await Promise.all([
getSession(request.headers.get("Cookie")),
request.formData(),
]);
let action = formData.get("_action");
let newTheme: Partial<Theme>;
if (action === "reset") {
newTheme = defaultTheme;
} else if (action === "dark") {
newTheme = darkTheme;
} else {
newTheme = {};
for (let key of Object.keys(defaultTheme) as Array<keyof Theme>) {
let color = formData.get(key);
if (typeof color !== "string" || !color) {
return json(`missing color ${key} in input`);
}
newTheme[key] = color;
}
}
session.set("theme", newTheme);
return redirect("/demos/theme", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
};
export default function ThemesDemo() {
// https://remix.run/api/remix#useloaderdata
let theme = useLoaderData<Theme>();
// https://remix.run/api/remix#useactiondata
let actionMessage = useActionData<string>();
return (
<div className="remix__page">
<main>
<h2>Custom Themes!</h2>
<p>
This is an example of using session storage to generate a custom CSS
file for your user based on their preferences.
</p>
{/* reloadDocument is used to force the browser to reload stylesheets */}
<Form reloadDocument method="post" className="remix__form">
<h3>Colors</h3>
<p>
<i>Choose the colors to theme the website to your liking.</i>
</p>
<p>Default themes</p>
<div>
<button name="_action" value="reset">
Light
</button>
<button name="_action" value="dark">
Dark
</button>
</div>
<label>
<div>Foreground color</div>
<input
name="foreground"
type="color"
defaultValue={theme.foreground}
key={theme.foreground}
/>
</label>
<label>
<div>Background color</div>
<input
name="background"
type="color"
defaultValue={theme.background}
key={theme.background}
/>
</label>
<label>
<div>Links color</div>
<input name="links" type="color" defaultValue={theme.links} />
</label>
<label>
<div>Links hover color</div>
<input
name="links-hover"
type="color"
defaultValue={theme["links-hover"]}
key={theme["links-hover"]}
/>
</label>
<label>
<div>Border color</div>
<input
name="border"
type="color"
defaultValue={theme.border}
key={theme.border}
/>
</label>
<div>
<button>Save</button>
</div>
{actionMessage ? (
<p>
<b>{actionMessage}</b>
</p>
) : null}
</Form>
</main>
<aside>
<h3>Additional Resources</h3>
<ul>
<li>
Guide:{" "}
<a href="https://remix.run/guides/data-writes">Data Writes</a>
</li>
<li>
API:{" "}
<a href="https://remix.run/api/conventions#action">
Route Action Export
</a>
</li>
<li>
API:{" "}
<a href="https://remix.run/api/remix#useactiondata">
useActionData
</a>
</li>
<li>
API:{" "}
<a href="https://remix.run/api/remix#createcookiesessionstorage">
createCookieSessionStorage
</a>
</li>
</ul>
</aside>
</div>
);
}

This contains a form with color pickers, a loader to get the current theme and populate the form, and an action to update the theme in the session.

Next we will create a resource route at `app/routes/demos/custom-theme[.]css.ts`. The `[]` in the filename escapes the period and results in a URL for the route of `/demos/custom-theme.css`. In this route we will generate a CSS file based on the session we manipulated above in the `demos/theme` route:

import type { LoaderFunction } from "remix";
import type { Theme } from "~/themes";
import { getSession } from "~/session.server";
export let loader: LoaderFunction = async ({ request }) => {
let session = await getSession(request.headers.get("Cookie"));
let theme: Theme = session.get("theme");
let css = "";
if (theme) {
let properties = Object.entries(theme).map(
([property, color]) => `--color-${property}: ${color};`
);
css = `:root {${properties.join(" ")}}`;
}
return new Response(css, {
headers: {
"Content-Type": "text/css",
},
});
};

The final step is to add this new dynamic CSS file from our resource route to `app/root.tsx` in the `links` export. This should look something like:

/**
* The `links` export is a function that returns an array of objects that map to
* the attributes for an HTML `<link>` element. These will load `<link>` tags on
* every route in the app, but individual routes can include their own links
* that are automatically unloaded when a user navigates away from the route.
*
* https://remix.run/api/app#links
*/
export let links: LinksFunction = () => {
return [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: darkStylesUrl,
media: "(prefers-color-scheme: dark)",
},
{ rel: "stylesheet", href: deleteMeRemixStyles },
{ rel: "stylesheet", href: "/demos/custom-theme.css" },
];
};

We now have a fully customizable theme, per user, with zero client-side runtime just by combining the simple API's of links, actions, sessions, and resource-routes! 🤯

Now go head on over to remix.run and build something awesome!