After creating my Todo App multiple times for other Frameworks, I figured creating it for Remix would be easy. After all, I had already built the components in React for NextJS. I was wrong. Remix has its own eccentricities.
TL;DR#
Remix requires a few hacks to get the environment variables to work, but it is not too different from NextJS. Loaders are beautiful, and the idea has been copied into other Frameworks like Qwik and SolidJS. This article shows the pitfalls and configuration settings for a good Remix app with Firebase.
Firebase Config#
First, put your Firebase configuration data inside the .env
file as usual.
PUBLIC_FIREBASE_CONFIG={"apiKey":"...","authDomain":"..."...}
š Make sure your keys have quotes.
Using Environmental Variables#
Remix does not make this easy, as this was by far one of my biggest hurdles. In your root.ts
file, create a loader for your env
file.
export async function loader() {
return json({
ENV: {
PUBLIC_FIREBASE_CONFIG: process.env.PUBLIC_FIREBASE_CONFIG
},
});
}
Next, you have to save it to the Window
object. This is not my recommended code, but the recommended way by Remix. This should be placed in your Layout
component.
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(
data.ENV
)}`,
}}
/>
<Scripts />
Accessing the Variable#
I created a utils.ts
file to get the Firebase variable.
function isBrowser() {
return typeof window !== 'undefined';
}
export function getFirebaseConfig() {
const env = isBrowser()
? window.ENV
: process.env;
return JSON.parse(env.PUBLIC_FIREBASE_CONFIG);
}
It needs to be available in any environment.
š Interestingly enough, the process.env.NODE_ENV
works everywhere as expected.
Types#
I also found it useful to create a app/global.d.ts
file for storing the config types. You need to export something for it to show up throughout your app.
declare global {
interface Window {
ENV: {
PUBLIC_FIREBASE_CONFIG: string;
}
}
namespace NodeJS {
interface ProcessEnv {
PUBLIC_FIREBASE_CONFIG: string;
}
}
}
export { }
Setup#
I created a reusable Firebase hook. You donāt have to do this; you can export and import the variables wherever you want. Firebase is a singleton by default, so you will always call the same instance. However, it is good practice to do this as most classes wonāt use singletons.
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { useShared } from './use-shared';
import { getFirebaseConfig } from './utils';
const firebase_config = getFirebaseConfig();
export const app = getApps().length
? getApp()
: initializeApp(firebase_config);
const _useFirebase = () => ({
auth: getAuth(),
db: getFirestore()
});
export const useFirebase = (init?: boolean) =>
useShared('firebase', _useFirebase, init);
We could have put the initializeApp
in the use firebase
hook. However, it must be later exported and reused in the server About component.
Shared Component#
Notice I also use my reusable single provider useShared
. We only need to call useFirebase
once. We need access to them everywhere. This uses contexts under the hood in my use-shared-tsx
file.
import {
FC,
ReactNode,
createContext,
useContext,
type Context
} from "react";
const _Map = <T,>() => new Map<string, T>();
const Context = createContext(_Map());
export const Provider: FC<{ children: ReactNode }> = ({ children }) =>
<Context.Provider value={_Map()}>{children}</Context.Provider>;
const useContextProvider = <T,>(key: string) => {
const context = useContext(Context);
return {
set value(v: T) { context.set(key, v); },
get value() {
if (!context.has(key)) {
throw Error(`Context key '${key}' Not Found!`);
}
return context.get(key) as T;
}
}
};
export const useShared = <T, A>(
key: string,
fn: (value?: A) => T,
initialValue?: A
) => {
const provider = useContextProvider<Context<T>>(key);
if (initialValue !== undefined) {
const state = fn(initialValue);
const Context = createContext<T>(state);
provider.value = Context;
}
return useContext(provider.value);
};
You also need to configure the provider in your entry.client.ts
file.
startTransition(() => {
hydrateRoot(
document,
<Provider>
<StrictMode>
<RemixBrowser />
</StrictMode>
</Provider>
);
});
I talk more about my universal provider in my dev.to article.
User Hook#
My user hook is exactly the same as my NextJS Todo App, except you need to import the Firebase hook for the auth
and add it to the useEffect
dependency array.
import { useEffect, useState } from "react";
import { useShared } from "./use-shared";
import {
User,
onIdTokenChanged,
signOut,
signInWithPopup,
GoogleAuthProvider,
Auth
} from "firebase/auth";
import { useFirebase } from "./firebase";
export type userData = {
photoURL: string | null;
uid: string;
displayName: string | null;
email: string | null;
};
type UserState = {
loading: boolean;
data: userData | null;
};
export function _useUser(
initialValue: UserState = {
loading: true,
data: null
}
) {
const { auth } = useFirebase();
const _store = useState<UserState>(initialValue);
const setUser = _store[1];
useEffect(() => {
if (!auth) {
return;
}
setUser(v => ({ ...v, loading: true }));
// subscribe to user changes
return onIdTokenChanged(auth, (_user: User | null) => {
if (!_user) {
setUser({ data: null, loading: false });
return;
}
// map data to user data type
const { photoURL, uid, displayName, email } = _user;
const data = { photoURL, uid, displayName, email };
// print data in dev mode
if (process.env.NODE_ENV === 'development') {
console.log(data);
}
// set store
setUser({ loading: false, data });
});
}, [setUser, auth]);
return _store;
}
export const useUser = (initialValue?: UserState) =>
useShared('user', _useUser, initialValue);
export const loginWithGoogle = (auth: Auth | null) => {
if (!auth) {
return;
}
signInWithPopup(auth, new GoogleAuthProvider());
}
export const logout = (auth: Auth | null) => {
if (!auth) {
return;
}
signOut(auth);
};
Unfortunately, the same goes for the functions. You have to pass the auth
to your functions. You cannot use a custom hook inside a function in React, only inside another custom hook.
export const Login = () => {
const { auth } = useFirebase();
return <button type="button" className="..."
onClick={() => loginWithGoogle(auth)}>
Signin with Google
</button>
};
The same goes for the logout
.
Todos Hook#
The todos
work similarly, except you must import the db
hook.
export function useTodos(
_user: ReturnType<typeof useUser>
) {
const { db } = useFirebase();
const _store = useState<{
todos: TodoItem[],
loading: boolean
}>({
todos: [],
loading: true
});
const user = _user[0];
const setTodos = _store[1];
useEffect(() => {
setTodos(v => ({ ...v, loading: true }));
if (!user.data) {
setTodos({ loading: false, todos: [] });
return;
}
if (!db) {
return;
}
return onSnapshot(
// query realtime todo list
query(
collection(db, 'todos'),
where('uid', '==', user.data.uid),
orderBy('created')
), (q) => {
// get data, map to todo type
const data = snapToData(q);
/**
* Note: Will get triggered 2x on add
* 1 - for optimistic update
* 2 - update real date from server date
*/
// print data in dev mode
if (process.env.NODE_ENV === 'development') {
console.log(data);
}
// add to store
setTodos({ loading: false, todos: data });
});
}, [setTodos, user.data, db]);
return _store[0];
}
User and Todos Component#
The rest of the app is exactly the same as my NextJS Todo App with Firebase. Read that article for more in-depth explanations of the React part.
About Page#
My About page shows one document from the server.
import { doc, getDoc, getFirestore } from "firebase/firestore";
import { app } from "./firebase";
type AboutDoc = {
name: string;
description: string;
};
const db = getFirestore(app);
export const getAbout = async () => {
const aboutSnap = await getDoc(
doc(db, '/about/ZlNJrKd6LcATycPRmBPA')
);
if (!aboutSnap.exists()) {
throw 'Document does not exist!';
}
return aboutSnap.data() as AboutDoc;
};
Notice I import the app
only from the firebase.ts
file. This is for a specific reason. If we want to use Remix on a Cloudflare or Vercel Edge Function, we must use the Firestore Lite package.
import { doc, getDoc, getFirestore } from "firebase/firestore/lite";
This will call the Firebase Rest API instead of using gRPC functions. Cloudflare only has certain NodeJS packages available and does not support Firebase on the server; this is the one exception.
ā¢ļø You must add the native fetch option to your vite.config.ts
in order for Firestore Lite to work.
installGlobals({ nativeFetch: true });
Loader#
To load the About page on your route, create a routes/about.tsx
file.
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getAbout } from "~/lib/about.server";
export const loader = async () => {
const about = await getAbout();
return json(about);
};
export default function AboutPage() {
const about = useLoaderData<typeof loader>();
return (
<div className="flex items-center justify-center my-5">
<div className="border w-[400px] p-5 flex flex-col gap-3">
<h1 className="text-3xl font-semibold">{about.name}</h1>
<p>{about.description}</p>
</div>
</div>
);
}
Remix is known for its easy-of-use loader
. When you see server$
in other frameworks, they copied Remix. Import the useLoaderData
hook, and it loads your data as expected.
Bugs#
I had some other issues besides the pain of having to manually check for a window
object, the terrible environment variable techniques, or the lack of server components; Nuxt had server components before Remix. However, these were not Remix's fault.
Rollup#
There was a bug in Rollup. I had to override
to the fixed version. This is not a Remix problem but Viteās problem.
"overrides": {
"rollup": "4.15.0"
}
I have to add Remix was very quick to respond to the bug and give me a solution.
Remix Thoughts#
This was my first experience with Remix, and I had some issues. However, if youāre used to NextJS before Server Components, you might like Remix. If I had to pick, I would go with NextJS, but I know they plan on adding Server Components in the future. I think a lot of my bad experience was a fluke, but time will tell.
I want to build Firebase apps for everyone in all flavors.
Demo: Vercel Serverless
Repo: GitHub
J