Skip to main content

Build a Dapp Frontend

This is a continuation of the Getting Started tutorial, where you should have deployed two smart contracts to the public network. In this section, we'll create a web app that interacts with the contracts via RPC calls.

Let's get started.

Initialize a frontend toolchain​

You can build a Soroban app with any frontend toolchain or integrate it into any existing full-stack app. For this tutorial, we're going to use Astro. Astro works with React, Vue, Svelte, any other UI library, or no UI library at all. In this tutorial, we're not using a UI library. The Soroban-specific parts of this tutorial will be similar no matter what frontend toolchain you use.

If you're new to frontend, don't worry. We won't go too deep. But it will be useful for you to see and experience the frontend development process used by Soroban apps. We'll cover the relevant bits of JavaScript and Astro, but teaching all of frontend development and Astro is beyond the scope of this tutorial.

Let's get started.

You're going to need Node.js v18.14.1 or greater. If you haven't yet, install it now.

We want to initialize our current project as an Astro project. To do this, we can clone a template. You can find Soroban templates on GitHub by searching for repositories that start with "soroban-template-". For this tutorial, we'll use stellar/soroban-template-astro. We'll also use a tool called degit to clone the template without its git history. This will allow us to set it up as our own git project.

Since you have node and its package manager npm installed, you also have npx. Make sure you're no longer in your soroban-hello-world directory and then run:

npx degit stellar/soroban-template-astro first-soroban-app
cd first-soroban-app
git init
git add .
git commit -m "first commit: initialize from stellar/soroban-template-astro"

This project has the following directory structure, which we'll go over in more detail below.

β”œβ”€β”€ contracts
β”‚Β Β  β”œβ”€β”€ hello_world
β”‚Β Β  └── increment
β”œβ”€β”€ CONTRIBUTING.md
β”œβ”€β”€ Cargo.toml
β”œβ”€β”€ Cargo.lock
β”œβ”€β”€ initialize.js
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ packages
β”œβ”€β”€ public
β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ components
β”‚Β Β  β”‚Β Β  └── Card.astro
β”‚Β Β  β”œβ”€β”€ env.d.ts
β”‚Β Β  β”œβ”€β”€ layouts
β”‚Β Β  β”‚Β Β  └── Layout.astro
β”‚Β Β  └── pages
β”‚Β Β  └── index.astro
└── tsconfig.json

The contracts are the same ones you walked through in the previous steps of the tutorial.

Generate an NPM package for the Hello World contract​

Before we open the new frontend files, let's generate an NPM package for the Hello World contract. This is our suggested way to interact with contracts from frontends. These generated libraries work with any JavaScript project (not a specific UI like React), and make it easy to work with some of the trickiest bits of Soroban, like encoding XDR.

This is going to use the CLI command stellar contract bindings typescript:

stellar contract bindings typescript \
--network testnet \
--contract-id $(cat .stellar/contract-ids/hello_world.txt) \
--output-dir packages/hello_world

This project is set up as an NPM Workspace, and so the hello_world client library was generated in the packages directory at packages/hello_world.

We attempt to keep the code in these generated libraries readable, so go ahead and look around. Open up the new packages/hello_world directory in your editor. If you've built or contributed to Node projects, it will all look familiar. You'll see a package.json file, a src directory, a tsconfig.json, and even a README.

Generate an NPM package for the Increment contract​

Though we can run soroban contract bindings typescript for each of our contracts individually, the soroban-template-astro that we used as our template includes a very handy initialize.js script that will handle this for all of the contracts in our contracts directory.

In addition to generating the NPM packages, initialize.js will also:

  • Generate and fund our Stellar account
  • Build all of the contracts in the contracts dir
  • Deploy our contracts
  • Create handy contract clients for each contract

We have already taken care of the first three bullet points in earlier steps of this tutorial, so those tasks will be noops when we run initialize.js.

Configure initialize.js​

We need to make sure that initialize.js has all of the environment variables it needs before we do anything else. Copy the .env.example file over to .env. The environment variables set in .env are used by the initialize.js script.

cp .env.example .env

Let's take a look at the contents of the .env file:

# Prefix with "PUBLIC_" to make available in Astro frontend files
PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"

STELLAR_ACCOUNT="me"
STELLAR_NETWORK="standalone"

This .env file defaults to connecting to a locally running network, but we want to configure our project to communicate with Testnet, since that is where we deployed our contracts. To do that, let's update the .env file to look like this:

# Prefix with "PUBLIC_" to make available in Astro frontend files
-PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
+PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
-PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc"
+PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443"

-STELLAR_ACCOUNT="me"
+STELLAR_ACCOUNT="alice"
-STELLAR_NETWORK="standalone"
+STELLAR_NETWORK="testnet"
info

This .env file is used in the initialize.js script. When using the CLI, we can still use the network configuration we set up in the Setup step, or by passing the --rpc-url and --network-passphrase flags.

Run initialize.js​

First let's install the Javascript dependencies:

npm install

And then let's run initialize.js:

npm run init

As mentioned above, this script attempts to build and deploy our contracts, which we have already done. The script is smart enough to check if a step has already been taken care of, and is a no-op in that case, so it is safe to run more than once.

Call the contract from the frontend​

Now let's open up src/pages/index.astro and take a look at how the frontend code integrates with the NPM package we created for our contracts.

Here we can see that we're importing our generated helloWorld client from ../contracts/hello_world. We're then invoking the hello method and adding the result to the page.

src/pages/index.astro
---
import Layout from "../layouts/Layout.astro";
import Card from "../components/Card.astro";
import helloWorld from "../contracts/hello_world";
const { result } = await helloWorld.hello({ to: "you" });
const greeting = result.join(" ");
---

...

<h1>{greeting}</h1>

Let's see it in action! Start the dev server:

npm run dev

And open localhost:4321 in your browser. You should see the greeting from the contract!

You can try updating the { to: 'Soroban' } argument. When you save the file, the page will automatically update.

info

When you start up the dev server with npm run dev, you will see similar output in your terminal as when you ran npm run init. This is because the dev script in package.json is set up to run npm run init and astro dev, so that you can ensure that your deployed contract and your generated NPM pacakage are always in sync. If you want to just start the dev server without the initialize.js script, you can run npm run astro dev.

What's happening here?​

If you inspect the page (right-click, inspect) and refresh, you'll see a couple interesting things:

  • The "Network" tab shows that there are no Fetch/XHR requests made. But RPC calls happen via Fetch/XHR! So how is the frontend calling the contract?
  • There's no JavaScript on the page. But we just wrote some JavaScript! How is it working?

This is part of Astro's philosophy: the frontend should ship with as few assets as possible. Preferably zero JavaScript. When you put JavaScript in the frontmatter, Astro will run it at build time, and then replace anything in the {...} curly brackets with the output.

When using the development server with npm run dev, it runs the frontmatter code on the server, and injects the resulting values into the page on the client.

You can try building to see this more dramatically:

npm run build

Then check the dist folder. You'll see that it built an HTML and CSS file, but no JavaScript. And if you look at the HTML file, you'll see a static "Hello Soroban" in the <h1>.

During the build, Astro made a single call to your contract, then injected the static result into the page. This is great for contract methods that don't change, but probably won't work for most contract methods. Let's integrate with the incrementor contract to see how to handle interactive methods in Astro. -->

Call the incrementor contract from the frontend​

While hello is a simple view-only/read method, increment changes on-chain state. This means that someone needs to sign the transaction. So we'll need to add transaction-signing capabilities to the frontend.

The way signing works in a browser is with a wallet. Wallets can be web apps, browser extensions, standalone apps, or even separate hardware devices.

Install Freighter Extension​

Right now, the wallet that best supports Soroban is Freighter. It is available as a Firefox Add-on, as well as extensions for Chrome and Brave. Go ahead and install it now.

Once it's installed, open it up by clicking the extension icon. If this is your first time using Freighter, you will need to create a new wallet. Go through the prompts to create a password and save your recovery passphrase.

Go to Settings (the gear icon) β†’ Preferences and toggle the switch to Enable Experimental Mode. Then go back to its home screen and select "Test Net" from the top-right dropdown. Finally, if it shows the message that your Stellar address is not funded, go ahead and click the "Fund with Friendbot" button.

Now you're all set up to use Freighter as a user, and you can add it to your app.

Add the StellarWalletsKit and set it up​

Even though we're using Freighter to test our app, there are more wallets that support signing smart contract transactions. To make their integration easier, we are using the StellarWalletsKit library which allows us support all Stellar Wallets with a single library.

To install this kit we are going to include the next package:

npm install @creit.tech/stellar-wallets-kit

With the package installed, we are going to create a new simple file where our instantiated kit and simple state will be located. Create the file src/stellar-wallets-kit.ts and paste this:

src/stellar-wallets-kit.ts
import {
allowAllModules,
FREIGHTER_ID,
StellarWalletsKit,
} from "@creit.tech/stellar-wallets-kit";

const SELECTED_WALLET_ID = "selectedWalletId";

function getSelectedWalletId() {
return localStorage.getItem(SELECTED_WALLET_ID);
}

const kit = new StellarWalletsKit({
modules: allowAllModules(),
network: import.meta.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE,
// StellarWalletsKit forces you to specify a wallet, even if the user didn't
// select one yet, so we default to Freighter.
// We'll work around this later in `getPublicKey`.
selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID,
});

export const signTransaction = kit.signTransaction.bind(kit);

export async function getPublicKey() {
if (!getSelectedWalletId()) return null;
const { address } = await kit.getAddress();
return address;
}

export async function setWallet(walletId: string) {
localStorage.setItem(SELECTED_WALLET_ID, walletId);
kit.setWallet(walletId);
}

export async function disconnect(callback?: () => Promise<void>) {
localStorage.removeItem(SELECTED_WALLET_ID);
kit.disconnect();
if (callback) await callback();
}

export async function connect(callback?: () => Promise<void>) {
await kit.openModal({
onWalletSelected: async (option) => {
try {
await setWallet(option.id);
if (callback) await callback();
} catch (e) {
console.error(e);
}
return option.id;
},
});
}

In the code above, we instantiate the kit with desired settings and export it. We also wrap some kit functions and add custom functionality, such as augmenting the kit by allowing it to remember which wallet options was selected between page refreshes (that's the localStorage bit). The kit requires a selectedWalletId even before the user selects one, so we also work around this limitation, as the code comment explains. You can learn more about how the kit works in the StellarWalletsKit documentation

Now we're going to add a "Connect" button to the page which will open the kit's built-in modal, and prompt the user to use their preferred wallet. Once the user picks their preferred wallet and grants permission to accept requests from the website, we will fetch the public key and the "Connect" button will be replaced with a message saying, "Signed in as [their public key]".

Now let's add a new component to the src/components directory called ConnectWallet.astro with the following content:

src/components/ConnectWallet.astro
<div id="connect-wrap" class="wrap" aria-live="polite">
&nbsp;
<div class="ellipsis"></div>
<button style="display:none" data-connect aria-controls="connect-wrap">
Connect
</button>
<button style="display:none" data-disconnect aria-controls="connect-wrap">
Disconnect
</button>
</div>

<style>
.wrap {
text-align: center;
display: flex;
width: 18em;
margin: auto;
justify-content: center;
line-height: 2.7rem;
gap: 0.5rem;
}

.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
}
</style>

<script>
import { getPublicKey, connect, disconnect } from "../stellar-wallets-kit";

const ellipsis = document.querySelector(
"#connect-wrap .ellipsis",
) as HTMLElement;
const connectButton = document.querySelector("[data-connect]") as HTMLButtonElement;
const disconnectButton = document.querySelector(
"[data-disconnect]",
) as HTMLButtonElement;

async function showDisconnected() {
ellipsis.innerHTML = "";
ellipsis.removeAttribute("title");
connectButton.style.removeProperty("display");
disconnectButton.style.display = "none";
}

async function showConnected() {
const publicKey = await getPublicKey();
if (publicKey) {
ellipsis.innerHTML = `Signed in as ${publicKey}`;
ellipsis.title = publicKey ?? "";
connectButton.style.display = "none";
disconnectButton.style.removeProperty("display");
} else {
showDisconnected();
}
}

connectButton.addEventListener("click", async () => {
await connect(showConnected);
});

disconnectButton.addEventListener("click", async () => {
disconnect(showDisconnected);
});

if (await getPublicKey()) {
showConnected();
} else {
showDisconnected();
}
</script>

Some of this may look surprising. <style> and <script> tags in the middle of the page? Uncreative class names like wrap? import statements in a <script>? Top-level await? What's going on here?

Astro automatically scopes the styles within a component to that component, so there's no reason for us to come up with a clever names for our classes.

And all the script declarations get bundled together and included intelligently in the page. Even if you use the same component multiple times, the script will only be included once. And yes, you can use top-level await.

You can read more about this in Astro's page about client-side scripts.

The code itself here is pretty self-explanatory. We import kit from the file we created before. Then, when the user clicks on the sign-in button, we call the connect function we created in our stellar-wallets-kit.ts file above. This will launch the built-in StellarWalletsKit modal, which allows the user to pick from the wallet options we configured (we configured all of them, with allowAllModules). We pass our own setLoggedIn function as the callback, which will be called in the onWalletSelected function in stellar-wallets-kit.ts. We end by updating the UI, based on whether the user is currently connected or not.

Now we can import the component in the frontmatter of pages/index.astro:

pages/index.astro
 ---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
import helloWorld from "../contracts/hello_world";
+import ConnectWallet from '../components/ConnectWallet.astro'
...

And add it right below the <h1>:

pages/index.astro
 <h1>{greeting}</h1>
+<ConnectWallet />

If you're no longer running your dev server, go ahead and restart it:

npm run dev

Then open the page and click the "Connect" button. You should see Freighter pop up and ask you to sign in. Once you do, the button should be replaced with a message saying, "Signed in as [your public key]".

Now you're ready to sign the call to increment!

Call increment​

Now we can import the increment contract client from soroban_increment_contract and start using it. We'll again create a new Astro component. Create a new file at src/components/Counter.astro with the following contents:

src/components/Counter.astro
<strong>Incrementor</strong><br />
Current value: <strong id="current-value" aria-live="polite">???</strong><br />
<br />
<button data-increment aria-controls="current-value">Increment</button>

<script>
import { getPublicKey, kit } from "../stellar-wallets-kit";
import incrementor from "../contracts/soroban_increment_contract";
const button = document.querySelector(
"[data-increment]",
) as HTMLButtonElement;
const currentValue = document.querySelector("#current-value") as HTMLElement;

button.addEventListener("click", async () => {
const publicKey = await getPublicKey();

if (!publicKey) {
alert("Please connect your wallet first");
return;
} else {
incrementor.options.publicKey = publicKey;
incrementor.options.signTransaction = signTransaction;
}

button.disabled = true;
button.classList.add("loading");
currentValue.innerHTML =
currentValue.innerHTML +
'<span class="visually-hidden"> – updating…</span>';

try {
const tx = await incrementor.increment();
const { result } = await tx.signAndSend();

// Only use `innerHTML` with contract values you trust!
// Blindly using values from an untrusted contract opens your users to script injection attacks!
currentValue.innerHTML = result.toString();
} catch (e) {
console.error(e);
} finally {
button.disabled = false;
button.classList.remove("loading");
}
});
</script>

This should be somewhat familiar by now. We have a script that, thanks to Astro's build system, can import modules directly. We use document.querySelector to find the elements defined above. And we add a click handler to the button, which calls increment and updates the value on the page. It also sets the button to disabled and adds a loading class while the call is in progress to prevent the user from clicking it again and visually communicate that something is happening. For people using screen readers, the loading state is communicated with the visually-hidden span, which will be announced to them thanks to the aria tags we saw before.

The biggest difference from the call to greeter.hello is that this transaction gets executed in two steps. The initial call to increment constructs a Soroban transaction and then makes an RPC call to simulate it. For read-only calls like hello, this is all you need, so you can get the result right away. For write calls like increment, you then need to signAndSend before the transaction actually gets included in the ledger. You also need to make sure you set a valid publicKey and a signTransaction method.

info

Destructuring { result }: If you're new to JavaScript, you may not know what's happening with those const { result } lines. This is using JavaScript's destructuring feature. If the thing on the right of the equals sign is an object, then you can use this pattern to quickly grab specific keys from that object and assign them to variables. You can also name the variable something else, if you like. For example, try changing the code above to:

const { result: newValue } = ...

Also, notice that you don't need to manually specify Freighter as the wallet in the call to increment. This may change in the future, but while Freighter is the only game in town, these generated libraries automatically use it. If you want to override this behavior, you can pass a wallet option; check the latest Wallet interface in the template source for details.

Now let's use this component. In pages/index.astro, first import it:

pages/index.astro
 ---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
import helloWorld from "../contracts/hello_world";
import ConnectFreighter from '../components/ConnectFreighter.astro';
+import Counter from '../components/Counter.astro';
...

Then use it. Let's replace the contents of the instructions paragraph with it:

pages/index.astro
 <p class="instructions">
- To get started, open the directory <code>src/pages</code> in your project.<br />
- <strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
+ <Counter />
</p>

Check the page; if you're still running your dev server, it should have already updated. Click the "Increment" button; you should see a Freighter confirmation. Confirm, and... the value updates! πŸŽ‰

There's obviously some functionality missing, though. For example, that ??? is a bummer. But our increment contract doesn't give us a way to query the current value without also updating it.

Before you try to update it, let's streamline the process around building, deploying, and generating clients for contracts.

Take it further​

If you want to take it a bit further and make sure you understand all the pieces here, try the following:

  • Make a src/contracts folder with a greeter.ts and an incrementor.ts. Move the new Contract({ ... }) logic into those files. You may also want to extract the rpcUrl variable to a src/contracts/utils.ts file.
  • Add a get_value method to the increment contract, and use it to display the current value in the Counter component. When you run npm run dev, the initialize script will run and update the contract and the generated client.
  • Add a "Decrement" button to the Counter component.
  • Deploy your frontend. You can do this quickly and for free with GitHub. If you get stuck installing stellar-cli and deploying contracts on GitHub, check out how we did this.
  • Rather than using NPM scripts for everything, try using a more elegant script runner such as just. The existing npm scripts can then call just, such as "setup": "just setup".
  • Update the README to explain what this project is and how to use it to potential collaborators and employers πŸ˜‰

Troubleshooting​

Sometimes things go wrong. As a first step when troubleshooting, you may want to clone our tutorial repository and see if the problem happens there, too. If it happens there, too, then it may be a temporary problem with the Soroban network.

Here are some common issues and how to fix them.

Call to hello fails​

Sometimes the call to hello can start failing. You can obviously stub out the call and define result some other way to troubleshoot.

One of the common problems here is that the contract becomes archived. To check if this is the problem, you can re-run npm run init.

If you're still having problems, join our Discord (link above) or open an issue in GitHub.

All contract calls start throwing 403 errors​

This means that Testnet is down, and you probably just need to wait a while and try again.

Wrapping up​

Some of the things we did in this section:

  • We learned about Astro's no-JS-by-default approach
  • We added Astro components and learned how their script and style tags work
  • We saw how easy it is to interact with smart contracts from JavaScript by generating client libraries using stellar contract bindings typescript
  • We learned about wallets and Freighter

At this point, you've seen a full end-to-end example of building a contract on Stellar! What's next? You choose! You can: