How I got Solana working with Nuxt 3

I've been actively working on getting Solana wallets working with Nuxt 3 for an embarrassingly long time. It's been a crash course in Nuxt 3, Vite, Rollup, and all the issues with the ongoing transition from CommonJS to ES Modules. I can't say I had much fun during the process, but in retrospect it was a solid learning experience.

A few things before we get stuck in.

1. I'm still of the belief I have no idea what I'm doing here, and while I've tried to clean up this process as much as possible before publishing this, some of it might be unnecessary. Hit me up on Twitter or Discord if you have feedback.

2. I haven't been able to make this work with Cloudflare Workers yet, it looks like the Elliptic library is breaking things. I moved this site from Cloudflare Workers to Vercel to get this shipped in the meantime.

3. Loris Leiva, the legend who wrote the initial Vue implementation for Solana wallets, has an incoming PR which will improve things significantly.

Let's do this.

Getting Vite and Nitro happy

Add this to your package.json (and don't forget to run yarn afterwards):

"resolutions": {
  "borsh": "0.7.0"
}

Add this to your nuxt.config.ts:

vite: {
  optimizeDeps: {
    include: [
      '@solana/web3.js',
      '@solana/wallet-adapter-base',
    ]
  }
},
build: {
  transpile: [
    '@solana/web3.js',
    '@solana/wallet-adapter-base',
  ],
},
nitro: {
  rollupConfig: {
    external: [
      'borsh',
      'util',
      'secp256k1',
      '@solana/web3.js',
      '@solana/wallet-adapter-phantom',
      '@solana/wallet-adapter-base'
    ],
  }
},

Getting anything to work in Nuxt 3 at this stage (including tiptap, which I'm using for the WYSIWYG editor I'm typing this into) is likely going to be a case of trial and error putting it (and its dependancies) into one or more of optimizeDeps, transpile and rollupConfig.external as seen above.

Importing a wallet

There's a number of Node built-in and CommonJS gremlins in certain wallet adapters, and the way they're bundled together means if you try to import one of them from @solana/wallet-adapter-wallets, you're going to end up dealing with them all at once.

I'm happy only supporting Phantom at this stage, so here's what I did.

  const { PhantomWalletAdapter } = await import('@solana/wallet-adapter-phantom')
  const getPhantomWallet = () => ({
    name: 'Phantom',
    url: 'https://phantom.app',
    icon: icon,
    adapter: () => new PhantomWalletAdapter()
  })
  // use getPhantomWallet() as normal

This imports PhantomWalletAdapter directly from its package, and mimics the getPhantomWallet function you'd normally import from @solana/wallet-adapter-wallets.

That weird await import syntax is a dynamic import. There's more details about dynamic imports in the V8 documentation.

To make this work, you'll need to use it in a component inside Nuxt's ClientOnly component, or from an onMounted hook to ensure it only runs on the client.

If you're using the Vue/Vue UI Solana packages, you can pass the resulting getPhantomWallet() into the WalletProvider component with no problems, but I took a different route.

Using a store instead of WalletProvider & useWallet

Coming from relatively complicated Nuxt 2 projects, I'm most comfortable shoving everything in a store.

So that's exactly what I did here.

I'm using Pinia, though Vuex is likely fine as well if that's your thing.

import { defineStore } from 'pinia'

export const useSolana = defineStore('solana', {
  state: () => {
    return {
      wallet: null,
      adapter: null
    }
  },

  actions: {
    async getWallet() {
      const { PhantomWalletAdapter } = await import('@solana/wallet-adapter-phantom')
      const getPhantomWallet = () => ({
        name: 'Phantom',
        url: 'https://phantom.app',
        icon: 'data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjM0IiB3aWR0aD0iMzQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGxpbmVhckdyYWRpZW50IGlkPSJhIiB4MT0iLjUiIHgyPSIuNSIgeTE9IjAiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM1MzRiYjEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM1NTFiZjkiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9Ii41IiB4Mj0iLjUiIHkxPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZmIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZmZmIiBzdG9wLW9wYWNpdHk9Ii44MiIvPjwvbGluZWFyR3JhZGllbnQ+PGNpcmNsZSBjeD0iMTciIGN5PSIxNyIgZmlsbD0idXJsKCNhKSIgcj0iMTciLz48cGF0aCBkPSJtMjkuMTcwMiAxNy4yMDcxaC0yLjk5NjljMC02LjEwNzQtNC45NjgzLTExLjA1ODE3LTExLjA5NzUtMTEuMDU4MTctNi4wNTMyNSAwLTEwLjk3NDYzIDQuODI5NTctMTEuMDk1MDggMTAuODMyMzctLjEyNDYxIDYuMjA1IDUuNzE3NTIgMTEuNTkzMiAxMS45NDUzOCAxMS41OTMyaC43ODM0YzUuNDkwNiAwIDEyLjg0OTctNC4yODI5IDEzLjk5OTUtOS41MDEzLjIxMjMtLjk2MTktLjU1MDItMS44NjYxLTEuNTM4OC0xLjg2NjF6bS0xOC41NDc5LjI3MjFjMCAuODE2Ny0uNjcwMzggMS40ODQ3LTEuNDkwMDEgMS40ODQ3LS44MTk2NCAwLTEuNDg5OTgtLjY2ODMtMS40ODk5OC0xLjQ4NDd2LTIuNDAxOWMwLS44MTY3LjY3MDM0LTEuNDg0NyAxLjQ4OTk4LTEuNDg0Ny44MTk2MyAwIDEuNDkwMDEuNjY4IDEuNDkwMDEgMS40ODQ3em01LjE3MzggMGMwIC44MTY3LS42NzAzIDEuNDg0Ny0xLjQ4OTkgMS40ODQ3LS44MTk3IDAtMS40OS0uNjY4My0xLjQ5LTEuNDg0N3YtMi40MDE5YzAtLjgxNjcuNjcwNi0xLjQ4NDcgMS40OS0xLjQ4NDcuODE5NiAwIDEuNDg5OS42NjggMS40ODk5IDEuNDg0N3oiIGZpbGw9InVybCgjYikiLz48L3N2Zz4K',
        adapter: () => new PhantomWalletAdapter()
      })
      this.wallet = getPhantomWallet()
    },
    setWallet(wallet) {
      this.wallet = wallet
    },
    async connect() {
      try {
        this.adapter = await this.wallet.adapter()
        await this.adapter.connect()
      } catch (e) {
        console.log(e)
      }
    },
    async disconnect() {
      try {
        this.adapter.disconnect()
      } catch (e) {
        console.log(e)
      }
    }
  },
})

And my Wallet component, which also has all the Headless UI dropdown stuff in it, looks like this:

<script setup>
import { useSolana } from '~~/stores/solana'
const solana = useSolana()

onMounted(async () => {
  solana.getWallet()
})

const connect = () => {
  solana.connect()
}

const disconnect = () => {
  solana.disconnect()
}
</script>

Which also means I have access to the public key, and adapter functions like sendTransaction, anywhere in the app.

Some work to do here, but it works.

My next article here will be about building a login flow for a Solana wallet: using signMessage with a nonce on the client, then verifying the message and generating a JWT for authenticating with another service like Supabase or Hasura on the backend.

You can follow me on Twitter if you're interested.

A WIP project by
@timhnln