Build a Stacks app with Remix
Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. People are gonna love using your stuff.
Remix is a wonderful framework built on top of React. With @micro-stacks/react
and @micro-stacks/client
,
we will be able to create robust and high performance Stacks apps that provide a delightful user experience.
In this guide you will learn about:
- 🏗️ Setting up
@micro-stacks/react
- 🔒 Adding authentication via Stacks based wallets
- 🔥 How to share a user session between client and server
- 🤩 Ensuring a better user experience via SSR
Getting started
To get started, we're going to create a new Remix app with Typescript by running the following command and following the prompts:
npx create-remix
- Where would you like to create your app? ./micro-stacks-remix-example
- What type of app do you want to create? Just the basics
- Where do you want to deploy? Choose Remix if you're unsure; it's easy to change deployment targets. Vercel
- Do you want me to run
npm install
? No - TypeScript or JavaScript? TypeScript
Next up, we need to install our micro-stacks
related dependencies:
pnpm i @micro-stacks/client @micro-stacks/react
Client Provider
Our first step will be to import * as MicroStacks from "@micro-stacks/react";
and wrap the root.tsx
file in our
MicroStacks.ClientProvider
component. This component allows for all of the related hooks to access a shared session
client within the application. We've also added two props to the ClientProvider
: appName
and appIconUrl
.
// root.tsx
import type { MetaFunction } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
// import added here
import * as MicroStacks from '@micro-stacks/react';
export const meta: MetaFunction = () => ({
charset: 'utf-8',
title: 'New Remix App',
viewport: 'width=device-width,initial-scale=1',
});
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<MicroStacks.ClientProvider
appName={'New Remix App'}
appIconUrl={'https://remix.run/remix-v1.jpg'}
>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</MicroStacks.ClientProvider>
</body>
</html>
);
}
Authentication
Now that we've wrapped our app in the ClientProvider
, we can start making use of all the hooks and other functions
that @micro-stacks/react
exports. Here is a simple WalletConnectButton
component we can use to authenticate a user.
Wallet connect button
// components/wallet-connect-button.tsx
import { useAuth } from '@micro-stacks/react';
export const WalletConnectButton = () => {
const { authenticate, isLoading, signOut, isSignedIn } = useAuth();
const label = isLoading ? 'Loading...' : isSignedIn ? 'Sign out' : 'Connect Stacks wallet';
return (
<button
onClick={() => {
if (isSignedIn) void signOut();
else void authenticate();
}}
>
{label}
</button>
);
};
In our routes/index.tsx
file, we can import { WalletConnectButton } from "~/components/wallet-connect-button";
and render it:
// routes/index.tsx
import { WalletConnectButton } from "~/components/wallet-connect-button";
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
<h1>Welcome to Remix</h1>
<WalletConnectButton />
</div>
);
}
Now, if you click the button, you should see the state change to loading and the Hiro Web Wallet pop up. Before we authenticate, we should create a component that will let us know who is currently logged in:
User card component
import { useStxAddress } from "@micro-stacks/react";
export const UserCard = () => {
const stxAddress = useStxAddress();
if (!stxAddress) return <h2>No active session</h2>;
return (
<h2>{stxAddress}</h2>
);
};
And we can add that to our index route:
// routes/index.tsx
import { WalletConnectButton } from "~/components/wallet-connect-button";
import { UserCard } from "~/components/user-card";
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
<h1>Welcome to Remix</h1>
<WalletConnectButton />
<UserCard />
</div>
);
}
Authentication only on client
Great! now we've successfully implemented stacks authentication. However, if you play around with what we've got so far, and refresh the page many times after logging in, you'll notice that there is a momentary flash of the unauthenticated state.
We can do better! Since we're using Remix, a SSR focused framework, let's learn how we can share session state between the client and server.
Server side session data
At a high level, what we're going to add now is the ability for our app to share state between the client and server contexts.
What this means is we're able to "dehydrate" (aka deserialize) the micro-stacks
client state, and save it in a way that
the server is able to easily use. We're going to use cookies to share this state.
This gives us the ability to make use of an active session on the server and:
- fetch data for a currently signed in user
- gate certain parts of our app to authenticated users only
- no more flash of unauthenticated state
- and more!
Cookie sessions
To start off, we're going to create a new file: session.server.ts
in /app/common
. This file is based off the Remix
starter kit called the Blues Stack. See here for their version.
In this file we're going to create a few things:
sessionStorage
: using Remix'screateCookieSessionStorage
(see their docs)saveSession
: a helper function to save the session to a cookiedestroySession
: a helper function to remove the sessiongetSession
: a helper function to get the current session
import type { Session } from "@remix-run/server-runtime";
import { createCookieSessionStorage, json } from "@remix-run/node";
const DEFAULT_SECRET = "this_is_a_long_password";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 0,
path: "/",
sameSite: "lax",
secrets: [process.env.SECRET ?? DEFAULT_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
export async function saveSession(session: Session) {
return json(null, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7,
}),
},
});
}
export async function destroySession(session: Session) {
return json(null, {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
});
}
export async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
Micro-stacks Client on the server
Next up, we'll create another file in the same /app/common
folder named client.server.ts
. This file will contain our
micro-stacks specific code for replicating the client state on the server.
The first thing we'll add to this file is a function to create our micro-stacks
client on the server, createServerClient
.
We are going to pass it a custom storage driver, one that will make use of the cookie session storage we created above.
import type { Session } from '@remix-run/server-runtime/sessions';
import { createClient, createStorage } from '@micro-stacks/client';
export const createServerClient = (session: Session) =>
createClient({
storage: createStorage({
storage: {
getItem(key) {
return session.get(key);
},
setItem(key, value) {
session.set(key, value);
},
removeItem(key) {
session.unset(key);
},
},
}),
});
Action handler
Now, in that same file, we're going to add one more function that we're going to use in just a little bit. Let's add some
imports to make use of our session helper functions from session.server.ts
:
import { getSession, saveSession } from "~/common/session.server";
Then add the function serverSideSessionHandler
:
export async function serverSideSessionHandler(request: Request) {
const [session, data] = await Promise.all([
getSession(request),
request.formData(),
]);
const dehydratedState = data.get("dehydratedState") as string | undefined;
if (dehydratedState) {
const client = createServerClient(session);
client.hydrate(dehydratedState);
return saveSession(session);
}
return null;
}
At a high level, this function will:
- Get the current session and any
FormData
from the current request in parallel - Check to see if
dehydratedState
exists in theFormData
- If so, it will create a new server
Client
and thenhydrate
it - Finally it will save the session to the cookie, using the
saveSession
helper
API Routes
Now that we have all our server side helpers created, we need to create two API routes that our application can use to safely interact with setting or deleting the server side session. The two files we will create are:
/app/routes/session/save.ts
- this route will handle saving our session/app/routes/session/destroy.ts
- this route will handle removing our session
Saving the session
import type { ActionFunction } from "@remix-run/node";
import { serverSideSessionHandler } from "~/common/client.server";
export const action: ActionFunction = async ({ request }) => {
return serverSideSessionHandler(request);
};
Destroying the session
import type { ActionFunction } from '@remix-run/node';
import { destroySession, getSession } from '~/common/session.server';
export const action: ActionFunction = async ({ request }) => {
const session = await getSession(request);
return destroySession(session);
};
Authentication callbacks
Now, to tie everything together, we need to create one more file: /app/common/use-session-callbacks.ts
. This hook will contain
two methods that will make use of the API routes and session helpers we made previously.
handleSetSession
: this callback takes a string as an argument, and POSTs to the/session/save
route asFormData
handleDestroySession
: this callback will POST to the/session/destroy
route
Both of these callbacks make use of the useFetcher
hook from Remix. Check out their docs if you want to learn more.
import { useFetcher } from "@remix-run/react";
import { useCallback } from "react";
export const useSessionCallbacks = () => {
const fetcher = useFetcher();
/**
* Session callback
*
* This function will pass the dehydrated state of the micro-stacks client
* to the /session/save endpoint via a Remix Action.
*
*/
const handleSetSession = useCallback(
async (dehydatedState: string) => {
console.log("setting session");
const data = new FormData();
data.set("dehydratedState", dehydatedState);
await fetcher.submit(data, {
method: "post",
action: "/session/save",
});
},
[fetcher]
);
/**
* Sign out callback
*
* This function will destroy a session by sending a POST request to
* /session/destroy.
*/
const handleDestroySession = useCallback(async () => {
await fetcher.submit(null, {
method: "post",
action: "/session/destroy",
});
}, [fetcher]);
return {
handleSetSession,
handleDestroySession,
};
};
Hooking into authentication
Now that we have all the pieces put together, if we go back to our /app/root.tsx
file, we can import our new
useSessionCallbacks
hook, add two props to the MicroStacks.ClientProvider
component, and we should be good to go!
The two props we wil be:
onPersistState
: this is a callback that runs anytime there is a state change that needs to be persistedonSignOut
: this callback runs when the user signs out of the current session
// root.tsx
import type { MetaFunction } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
import * as MicroStacks from '@micro-stacks/react';
import { useSessionCallbacks } from '~/common/use-session-callbacks';
export const meta: MetaFunction = () => ({
charset: 'utf-8',
title: 'New Remix App',
viewport: 'width=device-width,initial-scale=1',
});
export default function App() {
const { handleSetSession, handleDestroySession } = useSessionCallbacks();
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<MicroStacks.ClientProvider
appName={'New Remix App'}
appIconUrl={'https://remix.run/remix-v1.jpg'}
onPersistState={handleSetSession}
onSignOut={handleDestroySession}
>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</MicroStacks.ClientProvider>
</body>
</html>
);
}
Wrap up
To wrap up, in this guide we covered:
- Creating a new Remix app
- Installing out micro-stacks related dependencies
- Adding Stacks auth
- Creating some session related helpers
- Creating API routes for saving/destroying session
- Hooking into authentication callbacks
- Fetching the current session and passing it to
ClientProvider