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 again turn to the stellar contract init command, which has a --frontend-template flag that allows us to pass the url of a frontend template repository. As we learned in Storing Data, stellar contract init will not overwrite existing files, and is safe to use to add to an existing project.

From our soroban-hello-world directory, run the following command to add the Astro template files.

stellar contract init ./ \
--frontend-template https://github.com/stellar/soroban-astro-template

This will add the following to your project, which we'll go over in more detail below.

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

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_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"

SOROBAN_ACCOUNT="me"
SOROBAN_NETWORK="standalone"

# env vars that begin with PUBLIC_ will be available to the client
PUBLIC_SOROBAN_RPC_URL=$SOROBAN_RPC_URL

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_SOROBAN_NETWORK_PASSPHRASE="Standalone Network ; February 2017"
+PUBLIC_SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
-PUBLIC_SOROBAN_RPC_URL="http://localhost:8000/soroban/rpc"
+PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org:443"

-SOROBAN_ACCOUNT="me"
+SOROBAN_ACCOUNT="alice"
-SOROBAN_NETWORK="standalone"
+SOROBAN_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,
WalletNetwork,
} from "@creit.tech/stellar-wallets-kit";

const kit: StellarWalletsKit = new StellarWalletsKit({
modules: allowAllModules(),
network: WalletNetwork.TESTNET,
selectedWalletId: FREIGHTER_ID,
});

const connectionState: { publicKey: string | undefined } = {
publicKey: undefined,
};

function loadedPublicKey(): string | undefined {
return connectionState.publicKey;
}

function setPublicKey(data: string): void {
connectionState.publicKey = data;
}

export { kit, loadedPublicKey, setPublicKey };

In the code above, we created an instance of the kit and two simple functions that will take care of "setting" and "loading" the public key of the user. This lets us use the user's public key elsewhere in our code. The kit is started with Freighter as the default wallet, and the Testnet network as the default network. 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">
<div class="ellipsis">
<button data-connect aria-controls="connect-wrap">Connect</button>
</div>
</div>

<style>
.wrap {
text-align: center;
}

.ellipsis {
line-height: 2.7rem;
margin: auto;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
}
</style>

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

const ellipsis = document.querySelector("#connect-wrap .ellipsis");
const button = document.querySelector("[data-connect]");

async function setLoggedIn(publicKey: string) {
ellipsis.innerHTML = `Signed in as ${publicKey}`;
ellipsis.title = publicKey;
}

button.addEventListener("click", async () => {
button.disabled = true;

try {
await kit.openModal({
onWalletSelected: async (option) => {
try {
kit.setWallet(option.id);
const { address } = await kit.getAddress();
setPublicKey(address);
await setLoggedIn(address);
} catch (e) {
console.error(e);
}
},
});
} catch (e) {
console.error(e);
}

button.disabled = false;
});
</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 the wallets kit from the file we created before. Then, when the user clicks on the button, we launch the built-in modal do display to the user connection options. Once the user picks their preferred wallet, we set it as the wallets kit's default wallet before requesting and saving the address.

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 { kit, loadedPublicKey } from "../stellar-wallets-kit";
import incrementor from "../contracts/soroban_increment_contract";
const button = document.querySelector("[data-increment]");
const currentValue = document.querySelector("#current-value");

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

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

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

const tx = await incrementor.increment();

try {
const { result } = await tx.signAndSend({
signTransaction: async (xdr) => {
return await kit.signTransaction(xdr);
},
});

// 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);
}

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.

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: