Build A tRPC Sign-In With Ethereum Monorepo

Build A Web App That Utilizes tRPC With SIWE For Authentication

·

25 min read

Build A tRPC Sign-In With Ethereum Monorepo

NOTE: This is a first draft and I will be updating this shortly with all the code.

Building With tRPC

In this tutorials we'll be building a monorepo setup with Lerna that has a backend built with tRPC and a frontend built with ViteJS React Typescript.

This is a full walkthrough with the goal is to give you a guide/walkthrough on how you can set this up and all the nuances with getting SIWE (pronounced see-wee - just found this out) working with a tRPC backend.

tRPC + React SIWE Monorepo

There's A Video Walkthrough!

What Is tRPC

First off, what is this tRPC thing? tRPC is a lightweight typesafe alternative to GraphQL. It comes with all the advantages of batch requests and automatic type suggestions in your frontend without having to generate to write a schema.

The one caveat to this is that tRPC is meant to be used in a monorepo, which is why we are using Lerna to create our base for the mono repository.

If you aren't sold yet, just wait until we get to the frontend to see the magic of type definitions automatically showing up.

tRPC.io Website

Requirements

Make you have the following installed on your computer.

  • nvm or node v18.12.1
  • pnpm v7.15.0

Monorepos & Lerna

If you aren't familiar with monorepos (mono repositories or singular repositories), they are a single repository that holds all code in one "monolithic" source. In this case, you would multiple services all stored in one place. For example you might have a frontend folder and a backend folder in the same git repository.

To help better manage this, there are existing libraries that aid with setting up and managing monorepos better. Lerna is one of those libraries, which we will be using.

learn.js.org

To use Lerna, we'll start by creating a new folder and setting up our monorepo inside of it.

# Our project directory
mkdir trpc-siwe-monorepo;
cd trpc-siwe-monorepo;

# Lerna scaffolding out the base code for us
pnpx lerna init;

# Installing the dependencies
pnpm install;

# Making sure we initiate git to track files
git init;

ExpressJS Backend

Now that we have our monorepository setup, we can now start building out backend. You might be confused by the title of this section as "Express Backend" when this article is about tRPC. The reason we are still opting for ExpressJS is because tRPC has an adapter that works with Express.

Why would you still want to use Express when you can use tRPC directly as a server? One of the main reasons for this is because although tRPC and GraphQL offer great advantages, there are still some things that are best done in REST endpoints, like file uploading.

By having an ExpressJS and tRPC backend API, it will give us the flexibility of both worlds and allow us to take advantage of all the libraries that currently work with ExpressJS.

Backend Folder Setup & Dependencies

Let's start by creating our backend folder for our API.

# From ./trpc-siwe-monorepo

mkdir packages/trpc;

Next we'll install the necessary dependencies.

# From ./trpc-siwe-monorepo

cd packages/trpc;

pnpm init;

pnpm install typescript siwe iron-session express  ethers  dotenv cors @types/cores @types/express @types/node;

pnpm install -D nodemon ts-node;

With our dependencies installed, we now need to configure it to work with TypeScript. We'll run this script to generate our tsconfig.json file.

# From ./trpc-siwe-monorepo/packages/trpc

./node_modules/.bin/tsc --init;

Let's create our existing folders and files for our backend API.

# From ./trpc-siwe-monorepo/packages/trpc

mkdir ./src;
touch ./src/server.ts;
touch ./src/app.ts;
touch ./.env;
touch ./.env.example;
touch ./.gitignore;

File: ./packages/trpc/.gitignore

node_modules
.env

File: ./packages/trpc/.env

File: ./packages/trpc/.env.example

DEV TIP: Whenever I'm creating a new repository with environment variables I always make sure to create a example copy of the origin environment variable file so that the next developer is able to easily copy it and see which values they need to replace.

PORT=5001

When it comes to my ExpressJS structure, I usually separate both my server listening and my app with all the routes to make things a bit cleaner and also help with potential testing down the line.

DEV TIP: You'll see a lot of comments with "===" and the reason I do this to make it easier to organize and visually see breaks in the code to find things faster.

File: ./packages/trpc/src/app.ts

// Imports
// ========================================================
import express from 'express';
import cors from 'cors';

// Constants
// ========================================================
const app = express();

// Config
// ========================================================
app.use(express.json());
app.use(cors({
    credentials: true,
    // Modify this to whitelist certain requests
    origin: (origin, callback) => {
        callback(null, true);
    }
}));

// Routes
// ========================================================
/**
 * Our first health check route
 */
app.get('/healthz', (_req, res) => {
    return res.json({ ok: true });
});

// Exports
// ========================================================
export default app;

File: ./packages/trpc/src/server.ts

// Imports
// ========================================================
import app from "./app";

// Constants
// ========================================================
const PORT = process.env.PORT || 5001;

// Server
// ========================================================
app.listen(PORT, () => console.log(`Listening on port ${PORT}.`));

Let's get our server up and running to test that it's working correctly, but adding a run script to our package.json.

File: ./packages/trpc/package.json

{
  "name": "trpc",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
+    "dev": "nodemon src/server.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.14",
    "@types/node": "^18.11.9",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "ethers": "^5.7.2",
    "express": "^4.18.2",
    "iron-session": "^6.3.1",
    "siwe": "^1.1.6",
    "typescript": "^4.9.3"
  },
  "devDependencies": {
    "nodemon": "^2.0.20",
    "ts-node": "^10.9.1"
  }
}

Running our server and checking out the /healthz route we should get the following:

pnpm run dev;

# Expected Output:
# > nodemon src/server.ts
# 
# [nodemon] 2.0.20
# [nodemon] to restart at any time, enter `rs`
# [nodemon] watching path(s): *.*
# [nodemon] watching extensions: ts,json
# [nodemon] starting `ts-node src/server.ts`
# Listening on port 5001.

Express Server http://localhost:5001/healthz

Great out server is up and running, and now we can start adding tRPC functionality.

Creating Our First tRPC Route

Install the necessary dependencies.

pnpm install @trpc/server @trpc/server/adapters/express;

Let's modify our app.ts to include our first tRPC route.

File: ./packages/trpc/src/app.ts

// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
import { inferAsyncReturnType, initTRPC } from '@trpc/server';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';

// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });

// Types
// ========================================================

type Context = inferAsyncReturnType<typeof createContext>;

// Constants
// ========================================================
const app = express();
// Initiated tRPC instance for routes
const t = initTRPC.context<Context>().create();

// Config
// ========================================================
app.use(express.json());
app.use(cors({
    credentials: true,
    // Modify this to whitelist certain requests
    origin: (origin, callback) => {
        callback(null, true);
    }
}));

// tRPC Routes
// ========================================================
const appRouter = t.router({
    getUser: t.procedure.query((_req) => {
        return { hello: 'there' }
    })
});

// Routes
// ========================================================
/**
 * Our first health check route
 */
app.get('/healthz', (_req, res) => {
    return res.json({ ok: true });
});

/**
 * Support for a dedicated tRPC route
 */
 app.use(
    '/trpc',
    trpcExpress.createExpressMiddleware({
        router: appRouter,
        createContext
    })
);

// Exports
// ========================================================
export default app;

Let's run our server again but this time we'll request /trpc/getUser.

Express Running With tRPC & Requesting the route /trpc/getUser

Refactor For More Routes

We could keep adding more and more routes to our existing app.ts file but it might turn out to create a huge file. To prevent this, we'll split the routes into separate folders to make it easier to manage.

To start, we'll need to separate our tRPC instance to make it its own file.

File: ./src/trpc.ts

// Imports
// ========================================================
import { inferAsyncReturnType, initTRPC } from '@trpc/server';
import { createContext } from './app';

// Types
// ========================================================
type Context = inferAsyncReturnType<typeof createContext>;

// Config
// ========================================================
// Initiated tRPC instance for routes
const t = initTRPC.context<Context>().create();
const router = t.router;
const publicProcedure = t.procedure;
const mergeRouters = t.mergeRouters;

// Exports
// ========================================================
export { 
    Context,
    t,
    router,
    publicProcedure,
    mergeRouters
}

Next we'll need to modify our app.ts to accommodate for these changes.

File: ./src/trpc.ts

// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';
// We'll be creating this next
import appRouter from './router';

// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });

// Constants
// ========================================================
const app = express();

// Config
// ========================================================
app.use(express.json());
app.use(cors({
    credentials: true,
    // Modify this to whitelist certain requests
    origin: (origin, callback) => {
        callback(null, true);
    }
}));

// Routes
// ========================================================
/**
 * Our first health check route
 */
app.get('/healthz', (_req, res) => {
    return res.json({ ok: true });
});

/**
 * Support for a dedicated tRPC route
 */
 app.use(
    '/trpc',
    trpcExpress.createExpressMiddleware({
        router: appRouter,
        createContext
    })
);

// Exports
// ========================================================
export default app;
export {
    createContext
}

Now that we have refactored things, we can go ahead and create our routers utilizing the new mergeRouters functionality.

# From './
mkdir packages/trpc/src/router;
mkdir packages/trpc/src/router/user;
touch packages/trpc/src/router/index.ts;
touch packages/trpc/src/router/user/index.ts;

File: ./packages/trpc/src/router/index.ts

// Imports
// ========================================================
import { mergeRouters } from '../trpc';
// We'll be creating this next
import UserRouter from './user';

// Routes
// ========================================================
const appRouter = mergeRouters(UserRouter);

// Exports
// ========================================================
export default appRouter;

Next up is creating our user router.

File: ./packages/trpc/src/router/user/index.ts

// Imports
// ========================================================
import { router, publicProcedure } from "../../trpc";

// User Router
// ========================================================
const UserRouter = router({
    getUser: publicProcedure.query(() => {
        return { hello: 'there from merged routing' }
    })
});

// Exports
// ========================================================
export default UserRouter;

If everything works correctly, we should be getting the following when visiting /trpc/getUser:

Express tRPC Using Merge Routing & Requesting the route /trpc/getUser

Sign-In With Ethereum

To take advantage of Sign-In With Ethereum (SIWE), we'll need to add support for iron-session in our app.ts file and add some additional configurations to our environment variables.

File: ./packages/trpc/src/app.ts

// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';
import { ironSession } from 'iron-session/express';
import appRouter from './router';

// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });

// Constants
// ========================================================
const app = express();

// Config
// ========================================================
app.use(express.json());
app.use(cors({
    credentials: true,
    // Modify this to whitelist certain requests
    origin: (origin, callback) => {
        callback(null, true);
    }
}));
app.use(ironSession({
    cookieName: 'siwe',
    password: process.env.IRON_SESSION_PASSWORD || 'UNKNOWN_IRON_SESSION_PASSWORD_32',
    cookieOptions: {
        secure: process.env.NODE_ENV === 'production',
    },
}));

// Routes
// ========================================================
/**
 * Our first health check route
 */
app.get('/healthz', (_req, res) => {
    return res.json({ ok: true });
});

/**
 * Support for a dedicated tRPC route
 */
 app.use(
    '/trpc',
    trpcExpress.createExpressMiddleware({
        router: appRouter,
        createContext
    })
);

// Exports
// ========================================================
export default app;
export {
    createContext
}

Now that we have our routes, let's replace our user route with a new auth route and add the functionality we'll need for SIWE.

# From ./
mv ./packages/trpc/src/router/user ./packages/trpc/src/router/auth;

Refactor our main router/index.ts file to adjust for this.

File: ./packages/trpc/src/router/index.ts

// Imports
// ========================================================
import { mergeRouters } from '../trpc';
import AuthRouter from './auth';

// Routes
// ========================================================
const appRouter = mergeRouters(AuthRouter);

// Exports
// ========================================================
export default appRouter;

To support SIWE, we'll start by adding our nonce functionality.

File: ./packages/trpc/src/router/auth/index.ts

// Imports
// ========================================================
import { generateNonce } from 'siwe';
import { router, publicProcedure } from "../../trpc";

// User Router
// ========================================================
const AuthRouter = router({
    authNonce: publicProcedure.query(async ({ ctx }) => {
        // Get current date to setup session expiration
        const currentDate = new Date();

        // Setup Session
        ctx.req.session.nonce = generateNonce();
        ctx.req.session.issuedAt = currentDate.toISOString();
        ctx.req.session.expirationTime = new Date(
            currentDate.getTime() + 5 * 60 * 1000, // 5 minutes from the current time
        ).toISOString();

        // Save Session
        await ctx.req.session.save();

        // Return
        return {
            nonce: ctx.req.session.nonce,
            issuedAt: ctx.req.session.issuedAt,
            expirationTime: ctx.req.session.expirationTime
        }
    })
});

// Exports
// ========================================================
export default AuthRouter;

You may get some typing errors with session, but we'll fix this by adding the necessary types.

# From ./
mkdir ./packages/trpc/types;
mkdir ./packages/trpc/types/iron-session;
touch ./packages/trpc/types/iron-session/index.d.ts;

Now we just need to fill in our types for iron-session.

File: ./packages/trpc/types/iron-session/index.d.ts

// Imports
// ========================================================
import 'iron-session';
import { SiweMessage } from 'siwe';

// Type
// ========================================================
declare module 'iron-session' {
    interface IronSessionData {
        nonce?: string;
        issuedAt?: string;
        expirationTime?: string;
        siwe?: SiweMessage;
    }
};

We'll also need to add support for this in our package.json file.

File: ./packages/package.json

{
  "name": "trpc",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
-    "dev": "nodemon src/server.ts",
+    "dev": "nodemon --files src/server.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@trpc/server": "10.0.0-rc.8",
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.14",
    "@types/node": "^18.11.9",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "ethers": "^5.7.2",
    "express": "link:@trpc/server/adapters/express",
    "iron-session": "^6.3.1",
    "siwe": "^1.1.6",
    "typescript": "^4.9.3"
  },
  "devDependencies": {
    "nodemon": "^2.0.20",
    "ts-node": "^10.9.1"
  }
}

Now if run our sever again and request our new route /trpc/authNonce we should get an updated response.

# From ./packages/trpc
pnpm run dev;

# Expected Ouput:
# > nodemon --files src/server.ts
# 
# [nodemon] 2.0.20
# [nodemon] to restart at any time, enter `rs`
# [nodemon] watching path(s): *.*
# [nodemon] watching extensions: ts,json
# [nodemon] starting `ts-node --files src/server.ts`
# Listening on port 5001.

Express tRPC & Requesting /trpc/authNonce

Next we'll add the remaining Sign-In With Ethereum routes, but before that we're going to take advantage of zod a TypeScript-first schema validation package.

# From ./packages/trpc

pnpm install zod;

NOTE: If you get this error, try removing both of your node_modules, running a fresh install, uninstalling the dependency, and then installing it back.

# From './package/trpc
Error: Cannot find module 'express'
Require stack:

# Solution
pnpm uninstall express;
pnpm install express;

Now we can take advantage of it for our tRPC mutations.

File: ./packages/trpc/src/router/auth/index.ts

// Imports
// ========================================================
import { generateNonce } from 'siwe';
import { z } from 'zod';
import { SiweMessage } from 'siwe';
import { router, publicProcedure } from "../../trpc";

// User Router
// ========================================================
const AuthRouter = router({
    /**
     * Nonce
     */
    authNonce: publicProcedure.query(async ({ ctx }) => {
        // Get current date to setup session expiration
        const currentDate = new Date();

        // Setup Session
        ctx.req.session.nonce = generateNonce();
        ctx.req.session.issuedAt = currentDate.toISOString();
        ctx.req.session.expirationTime = new Date(
            currentDate.getTime() + 5 * 60 * 1000, // 5 minutes from the current time
        ).toISOString();

        // Save Session
        await ctx.req.session.save();

        // Return
        return {
            nonce: ctx.req.session.nonce,
            issuedAt: ctx.req.session.issuedAt,
            expirationTime: ctx.req.session.expirationTime
        }
    }),
    /**
     * Verify
     */
    authVerify: publicProcedure
        .input(z.object({ message: z.string(), signature: z.string() }))
        // To take advantge of input we need to access the full request
        .mutation(async (req) => {
            try {
                const siweMessage = new SiweMessage(req.input.message);
                const fields = await siweMessage.validate(req.input.signature);

                // To access the express request to need to refer to ctx from the full request
                // As req.ctx.req
                if (fields.nonce !== req.ctx.req.session.nonce)
                    throw new Error('Invalid nonce.');

                    req.ctx.req.session.siwe = fields;
                    await req.ctx.req.session.save();
                    return { ok: true };
            } catch (error as any) {
                return { 
                    ok: false,
                    error: error?.message
                };
            }
        }),
    /**
     * Me
     */
    authMe: publicProcedure.query(async ({ ctx }) => {
        // Destroy session in case there was something there before and it was invalid
        if (!ctx.req.session?.siwe?.address) {
            ctx.req.session.destroy();
        }

        return { address: ctx.req.session.siwe?.address }
    }),
    /**
     * Logout
     */
    authLogout: publicProcedure.query(async ({ ctx }) => {
        ctx.req.session.destroy();
        return { ok: true };
    })
});

// Exports
// ========================================================
export default AuthRouter;

Let's test these new routes with curl to see their results.

# From ./

# Nonce
curl --location --request GET 'http://localhost:5001/trpc/authNonce' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive'

# Expected Output:
# {
#     "result": {
#         "data": {
#             "nonce": "czy7cvzSaY3mQBOo2",
#             "issuedAt": "2022-11-21T09:11:56.730Z",
#             "expirationTime": "2022-11-21T09:16:56.730Z"
#         }
#     }
# }

# Verify - With invalid message and signature
curl --location --request POST 'http://localhost:5001/trpc/authVerify' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
    "message": "hello",
    "signature": "goodbye"
}'

# Expected Output:
# {
#     "result": {
#         "data": {
#             "ok": false
#         }
#     }
# }

# Me
curl --location --request GET 'http://localhost:5001/trpc/authMe' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
    "message": "hello",
    "signature": "goodbye"
}'

# Expected Output:
# {
#     "result": {
#         "data": {}
#     }
# }

# Logout
curl --location --request GET 'http://localhost:5001/trpc/authLogout' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
    "message": "hello",
    "signature": "goodbye"
}'

# Expected Output:
# {
#     "result": {
#         "data": {
#             "ok": true
#         }
#     }
# }

Now that we have our backend, we can now built out our frontend to interact with it.

React ViteJS Frontend

We'll be using ViteJS as a client-side React application to interact with our backend.

# From ./
pnpm create vite packages/react;

# Expected Prompts:
# ? Package name: › packages-react
# 
# ? Select a framework: › - Use arrow-keys. Return to submit.
#     Vanilla
#     Vue
# ❯   React
#     Preact
#     Lit
#     Svelte
#     Others
#
# ? Select a variant: › - Use arrow-keys. Return to submit.
#     JavaScript
# ❯   TypeScript

If we run our React application we should get the default ViteJS UI.

# From ./packages/react;

pnpm install;
pnpm run dev;

# Expected Output:
#   VITE v3.2.4  ready in 506 ms
# 
#   ➜  Local:   http://127.0.0.1:5173/
#   ➜  Network: use --host to expose

Default ViteJS React TypeScript Scaffolded Client-Side Application

Support Tailwind Styling

We'll definitely want to change the UI and add support for SIWE. We'll start by installing our dependencies and configuring Tailwind.

# From ./packages/react

pnpm install -D tailwindcss postcss autoprefixer;
pnpm install wagmi ethers siwe;

Some more configurations with Tailwind:

# From ./packages/react

pnpx tailwindcss init -p;

File: ./packages/react/tailwind.config.cjs

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

File: ./packages/react/src/index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

Next I want to do some clean up to remove some files that won't be needed for our application.

# From ./packages/react

rm -rf src/assets;
rm src/App.css

Refactor Frontend For SIWE UI

To support the UI we want to interact with our tRPC Sign-In With Ethereum backend, we're going to modify it to include the necessary elements and do some organization to fit the following:

tRPC SIWE React Client-Side Application

File: ./packages/react/src/main.tsx

// Imports
// ========================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

// Render
// ========================================================
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

File: ./packages/react/src/App.tsx

// Imports
// ========================================================
import { useState } from "react";

// Component
// ========================================================
const App = () => {
  // State / Props
  const [state, setState] = useState<{
    isLoading?: boolean;
    isSignedIn?: boolean;
    nonce?: {
        expirationTime?: string;
        issuedAt?: string;
        nonce?: string;
    },
    address?: string;
    error?: Error | string,
  }>({});

  // Hooks

  // Requests

  // Functions

  // On Mount

  // Render
  return (
    <main>
      <div className="p-6">
        <h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>

        <button className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>

        <button className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button>

        <div className="border-t border-zinc-800 pt-4">
          <label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
          <code className="bg-zinc-800 text-white p-4 mb-10 block">&nbsp;</code>

          <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
          <label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
          <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>

          <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
          <label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
          <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>

          <button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
          <label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
          <code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
        </div>
      </div>
    </main>
  )
};

// Exports
// ========================================================
export default App;

To support SIWE, we're going to do need wrap the application in its provider, but we'll abstract it to allow for multiple providers later on.

# From ./

mkdir ./packages/react/src/providers;
mkdir ./packages/react/src/providers/wagmi;
touch ./packages/react/src/providers/index.tsx;
mkdir ./packages/react/src/providers/wagmi/index.tsx;

File: ./packages/react/src/providers/wagmi/index.tsx

// Imports
// ========================================================
import { createClient, WagmiConfig } from "wagmi";
import { getDefaultProvider } from 'ethers';

// Config
// ========================================================
const client = createClient({
    autoConnect: true,
    provider: getDefaultProvider(),
});

// Provider
// ========================================================
const WagmiProvider = ({ children }: { children: React.ReactNode }) => {
    return <WagmiConfig client={client}>{children}</WagmiConfig>
};

// Exports
// ========================================================
export default WagmiProvider;

Next we'll support for this provider in the root provider.

File: ./packages/react/src/providers/index.tsx

// Imports
// ========================================================
import WagmiProvider from "./wagmi";

// Root Provider
// ========================================================
const RootProvider = ({ children }: { children: React.ReactNode }) => {
    return <div>
        <WagmiProvider>
            {children}
        </WagmiProvider>
    </div>
};

// Exports
// ========================================================
export default RootProvider;

Lastly to support all these providers, we'll add it to our main.tsx file.

File: ./packages/react/src/main.tsx

// Imports
// ========================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import RootProvider from './providers';
import App from './App';
import './index.css';

// Render
// ========================================================
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <RootProvider>
      <App />
    </RootProvider>
  </React.StrictMode>
);

Wallet Connection Support

Next we'll want to hide certain elements if the user hasn't connected their wallet, and show another if they have.

File: ./packages/react/src/App.tsx

// Imports
// ========================================================
import { useState } from "react";
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';

// Component
// ========================================================
const App = () => {
  // State / Props
  const [state, setState] = useState<{
    isLoading?: boolean;
    isSignedIn?: boolean;
    nonce?: {
        expirationTime?: string;
        issuedAt?: string;
        nonce?: string;
    },
    address?: string;
    error?: Error | string,
  }>({});

  // Hooks
  const { address, isConnected } = useAccount();
  const { connect, error: errorConnect } = useConnect({
    connector: new InjectedConnector(),
  });
  const { disconnect, error: errorDisconnect } = useDisconnect();

  // Requests

  // Functions

  // On Mount

  // Render
  return (
    <main>
      <div className="p-6">
        <h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>

        {!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}

        {isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}

        {errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}

        {isConnected 
          ? <div className="border-t border-zinc-800 pt-4">
              <label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
              <label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
              <label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
              <label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
              <code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
            </div>
          : null}
      </div>
    </main>
  )
};

// Exports
// ========================================================
export default App;

With that updated functionality we should when the user has connected their wallet, disconnects, or has an error.

React tRPC SIWE - Wallet Not Connected

React tRPC SIWE - Wallet Connection Error

React tRPC SIWE - Wallet Successfully Connected

tRPC Support

Next up we need to add support for tRPC on the client-side and to do that we need to install some additional dependencies.

# From ./packages/react

pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query

Next we need to create a new tRPC file that we can use throughout the application that references the backend tRPC AppRouter.

# From ./packages/react

mkdir ./src/utils;
touch ./src/utils/trpc.ts

File: ./packages/react/src/utils/trpc.ts

// Imports
// ========================================================
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../../trpc/src/router';

// Config
// ========================================================
const trpc = createTRPCReact<AppRouter>();

// Exports
// ========================================================
export default trpc;

Next we'll add the necessary support for providers for tRPC and Tanstack React Query.

# From ./packages/react

mkdir ./src/providers/trpc;
mkdir ./src/providers/query;
touch ./src/providers/trpc/index.tsx;
touch ./src/providers/query/index.tsx;

File: ./packages/react/src/providers/query/index.tsx

// Imports
// ========================================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Config
// ========================================================
const queryClient = new QueryClient();

// Provider
// ========================================================
const QueryProvider = ({ children }: { children: React.ReactNode }) => {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
};

// Exports
// ========================================================
export default QueryProvider;
export {
    queryClient
}

For our tRPC provider, we'll need to add some new environment variables for our React application. Remember that ViteJS supports environment variables with a prefix of VITE_.

# From ./packages/react

echo "VITE_TRPC_SERVER_URL=\"http://localhost:5001\"" > ./.env.local.example;
cp ./.env.local.example ./.env.local

With that done, we can now create our tRPC provider.

File: ./packages/react/src/providers/trpc/index.tsx

// Imports
// ========================================================
import { httpBatchLink } from '@trpc/client';
import trpc from '../../utils/trpc';
import { queryClient } from '../query';

// Config
// ========================================================
const trpcClient = trpc.createClient({
    links: [
      httpBatchLink({
        url: `${import.meta.env.VITE_TRPC_SERVER_URL}/trpc`,
        // Needed to support session cookies
        fetch(url, options) {
            return fetch(url, {
                ...options,
                credentials: 'include'
            })
        }
      }),
    ],
});

// Provider
// ========================================================
const TRPCProvider = ({ children }: { children: React.ReactNode }) => {
    return <trpc.Provider client={trpcClient} queryClient={queryClient}>{children}</trpc.Provider>
};

// Exports
// ========================================================
export default TRPCProvider;

Lastly, we just need to add support for them in our RootProvider.

File: ./packages/react/src/providers/index.tsx

// Imports
// ========================================================
import WagmiProvider from "./wagmi";
import TRPCProvider from "./trpc";
import QueryProvider from "./query";

// Root Provider
// ========================================================
const RootProvider = ({ children }: { children: React.ReactNode }) => {
    return <div>
        <WagmiProvider>
            <TRPCProvider>
                <QueryProvider>
                    {children}
                </QueryProvider>
            </TRPCProvider>
        </WagmiProvider>
    </div>
};

// Exports
// ========================================================
export default RootProvider;

Now that we have tRPC setup with React, we can now start creating requests. We'll first start off by creating a request for a nonce.

File: ./packages/react/src/App.tsx

// Imports
// ========================================================
import { useEffect, useState } from "react";
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';
import trpc from './utils/trpc';

// Component
// ========================================================
const App = () => {
  // State / Props
  const [state, setState] = useState<{
    isLoading?: boolean;
    isSignedIn?: boolean;
    nonce?: {
        expirationTime?: string;
        issuedAt?: string;
        nonce?: string;
    },
    address?: string;
    error?: Error | string,
  }>({});

  // Hooks
  const { address, isConnected } = useAccount();
  const { connect, error: errorConnect } = useConnect({
    connector: new InjectedConnector(),
  });
  const { disconnect, error: errorDisconnect } = useDisconnect();

  // Requests
  const authNonce = trpc.authNonce.useQuery();

  // Functions

  // On Mount
  /**
   * Retrieve a new nonce every time the page loads
   */
  useEffect(() => {
    if (!isConnected || !authNonce.data) return;

    setState((x) => ({ ...x, nonce: authNonce.data }));
  }, [isConnected, authNonce?.data]);

  // Render
  return (
    <main>
      <div className="p-6">
        <h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>

        {!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}

        {isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}

        {errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}

        {isConnected 
          ? <div className="border-t border-zinc-800 pt-4">
              <label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
              <label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
              <label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
              <label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
              <code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
            </div>
          : null}
      </div>
    </main>
  )
};

// Exports
// ========================================================
export default App;

If everything works correctly, you should see an output in our UI with the nonce data.

React tRPC SIWE - Successful Request Of Nonce Data

SIWE Signature Prompt

Next we'll add support for our signature request prompt.

File: ./packages/react/src/App.tsx

// Imports
// ========================================================
import { useEffect, useState } from "react";
import { useAccount, useConnect, useDisconnect, useNetwork, useSignMessage } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';
import { SiweMessage } from 'siwe';
import trpc from './utils/trpc';

// Component
// ========================================================
const App = () => {
  // State / Props
  const [state, setState] = useState<{
    isLoading?: boolean;
    isSignedIn?: boolean;
    nonce?: {
        expirationTime?: string;
        issuedAt?: string;
        nonce?: string;
    },
    address?: string;
    error?: Error | string,
  }>({});

  // Hooks
  const { address, isConnected } = useAccount();
  const { connect, error: errorConnect } = useConnect({
    connector: new InjectedConnector(),
  });
  const { disconnect, error: errorDisconnect } = useDisconnect();
  const { chain } = useNetwork();
  const { signMessageAsync } = useSignMessage();

  // Requests
  const authNonce = trpc.authNonce.useQuery();
  const authVerify = trpc.authVerify.useMutation();

  // Functions
  /**
   * Performs SIWE Signature Prompt Request
   * @returns {void}
   */
  const signIn = async () => {
    try {
      const chainId = chain?.id
      if (!address || !chainId) return;

      setState((x) => ({ ...x, isLoading: true }));

      // Create SIWE message with pre-fetched nonce and sign with wallet
      const message = new SiweMessage({
          domain: window.location.host,
          address,
          statement: 'Sign in with Ethereum to the app.',
          uri: window.location.origin,
          version: '1',
          chainId,
          expirationTime: state.nonce?.expirationTime,
          issuedAt: state.nonce?.expirationTime,
          nonce: state.nonce?.nonce,
      });
      const signature = await signMessageAsync({
          message: message.prepareMessage(),
      });

      authVerify.mutate({ message, signature });
    } catch (error) {
      setState((x) => ({ ...x, isLoading: false, nonce: undefined, error: error as Error }));
    }
  };

  // On Mount
  /**
   * Retrieve a new nonce every time the page loads
   */
  useEffect(() => {
    if (!isConnected || !authNonce.data) return;

    setState((x) => ({ ...x, nonce: authNonce.data }));
  }, [isConnected, authNonce?.data]);

  /**
   * Hooks into auth verification and sets if the result is successful
   */
  useEffect(() => {
    if (!isConnected || !authNonce.data) return;

    if (authVerify.data?.ok) {
      setState((x) => ({ ...x, isSignedIn: true, isLoading: false }));
    } else {
      setState((x) => ({ ...x, isSignedIn: false, isLoading: false, error: authVerify.data?.error }));
    }
  }, [authVerify.data]);

  // Render
  return (
    <main>
      <div className="p-6">
        <h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>

        {!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}

        {isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}

        {errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}

        {isConnected 
          ? <div className="border-t border-zinc-800 pt-4">
              <label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>

              <button onClick={signIn} className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
              <label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
              <label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
              <code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>

              <button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
              <label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
              <code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
            </div>
          : null}
      </div>
    </main>
  )
};

// Exports
// ========================================================
export default App;

The result of a successful prompt and signature should show that the user is isSignedIn in the code section.

React tRPC SIWE - Successful Signature Verification

Adding AuthMe & AuthLogout

The final step to this would be to add the functionality to return if a user is signed in to verify the session and make sure to destroy the session with the logout.

File: ./packages/react/src/App.tsx


Code

If you want to see the full code repository, check out the GitHub link below.

What's Next

Look out for the next article to this that will demonstrate a way that you would gate certain routes and sections of your client-side React application with Sign-In With Ethereum and tRPC.

If you enjoyed this article, give it some love by sharing it on Twitter, Reddit, or anywhere.

In case you want to follow me for more updates, check out my Twitter at @codingwithmanny.