Guides
Building a Remix app

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 { openAuthRequest, isRequestPending, signOut, isSignedIn } = useAuth();
  const label = isRequestPending ? 'Loading...' : isSignedIn ? 'Sign out' : 'Connect Stacks wallet';
  return (
    <button
      onClick={() => {
        if (isSignedIn) void signOut();
        else void openAuthRequest();
      }}
    >
      {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 + micro-stacks</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 { useAccount } from '@micro-stacks/react';
 
export const UserCard = () => {
  const { stxAddress } = useAccount();
  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 + micro-stacks</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's createCookieSessionStorage (see their docs)
  • saveSession: a helper function to save the session to a cookie
  • destroySession: a helper function to remove the session
  • getSession: 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);
}

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/api/session/save.ts - this route will handle saving our session
  • /app/routes/api/session/destroy.ts - this route will handle removing our session

Saving the session

import { getSession, saveSession } from '~/common/session.server';
import type { ActionFunction } from '@remix-run/node';
 
export const action: ActionFunction = async ({ request }) => {
  const [session, data] = await Promise.all([getSession(request), request.formData()]);
 
  const dehydratedState = data.get('dehydratedState') as string | undefined;
 
  if (dehydratedState) {
    await session.set('dehydratedState', 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 the FormData
  • IF so, it will save the dehydratedState value to the session cookie, using the saveSession helper

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/hooks/use-session-callbacks.ts. This hook will contain two methods that will make use of the API routes and session helpers we made previously.

  • onPersistState: this callback takes a string as an argument, and POSTs to the /api/session/save route as FormData
  • onSignOut: this callback will POST to the /api/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 /api/session/save endpoint via a Remix Action.
   *
   */
  const onPersistState = useCallback(
    async (dehydatedState: string) => {
      const data = new FormData();
      data.set('dehydratedState', dehydatedState);
      await fetcher.submit(data, {
        method: 'post',
        action: '/api/session/save',
      });
    },
    [fetcher]
  );
 
  /**
   * Sign out callback
   *
   * This function will destroy a session by sending a POST request to
   * /api/session/destroy.
   */
  const onSignOut = useCallback(async () => {
    await fetcher.submit(null, {
      method: 'post',
      action: '/api/session/destroy',
    });
  }, [fetcher]);
 
  return {
    onPersistState,
    onSignOut,
  };
};

Hooking into authentication

Now that we have all the pieces put together, if we go back to our /app/root.tsx file, we can add our new useSessionCallbacks hook, add two props to the MicroStacks.ClientProvider component.

The two props we will be:

  • onPersistState: this is a callback that runs anytime there is a state change that needs to be persisted
  • onSignOut: this callback runs when the user signs out of the current session

Additionally, we need to fetch the current session in a Remix Loader function (read more about them here). We will pass down the current session to the MicroStacks.ClientProvider as the prop dehydratedState.

💡

Important: we only want to pass the dehydratedState on the server, and we want to use the function cleanDehydratedState to remove any instance of appPrivateKey from being passed unencrypted to the client for rehydration.

// root.tsx
 
import type { LoaderFunction, MetaFunction } from '@remix-run/server-runtime';
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from '@remix-run/react';
import { json } from '@remix-run/server-runtime';
 
import * as MicroStacks from '@micro-stacks/react';
import { cleanDehydratedState } from '@micro-stacks/client';
import { useSessionCallbacks } from '~/hooks/use-session-callbacks';
import { getSession } from '~/common/session.server';
 
export const meta: MetaFunction = () => ({
  charset: 'utf-8',
  title: 'New Remix App',
  viewport: 'width=device-width,initial-scale=1',
});
 
export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request);
  return json({
    // IMPORTANT:
    // because this data gets sent to the client,
    // we want to clean the dehydrated state.
    // This function will remove any instance of the `appPrivateKey`
    // from the data that gets sent to the client.
    dehydratedState: cleanDehydratedState(session.get('dehydratedState')),
  });
};
 
export default function App() {
  const { onPersistState, onSignOut } = useSessionCallbacks();
  const data = useLoaderData<{ dehydratedState: string | undefined }>();
 
  // only send it if we're on the server
  // the client will hydrate from localStorage
  const IS_SSR = typeof document === 'undefined';
  const dehydratedState = IS_SSR ? data.dehydratedState : undefined;
 
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <MicroStacks.ClientProvider
          appName={'Remix + micro-stacks'}
          appIconUrl={'/icon.png'}
          dehydratedState={dehydratedState}
          onPersistState={onPersistState}
          onSignOut={onSignOut}
        >
          <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