Skip to main content

5. Create an App

With two smart contracts deployed to a public network, you can now create a web app that interacts with them 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.

Then we want to initialize the current directory, soroban-tutorial, as an Astro project, but Astro doesn't like that. It wants to create a new directory. So let's go ahead and do that, then move all the contents of the new directory into its parent directory. From the original soroban-tutorial directory, run:

npm create [email protected] soroban-tutorial -- --template basics --install --no-git --typescript strictest

This will take a little while, as the --install option automatically installs the dependencies. Once it's done, let's move the contents of the new nested folder into the project root. Other project organization strategies are possible, but we find that it causes no problems to have the Node-specific web app stuff live right alongside the Rust-specific smart contract stuff and that keeping it all in the root just makes things simpler.

mv soroban-tutorial/* .
mv soroban-tutorial/.vscode .
cat soroban-tutorial/.gitignore >> .gitignore
rm soroban-tutorial/.gitignore
rmdir soroban-tutorial

This is a good time to commit your changes so that later on, you can clearly see the differences between what came from Astro's basics template and the Soroban-specific stuff we're going to add.

git add .
git commit -m "Initialize Astro project"

Generate an NPM package for the Hello World contract

Before we even 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 soroban contract bindings typescript:

soroban contract bindings typescript \
--network testnet \
--contract-id $(cat .soroban/hello-id) \
--output-dir node_modules/hello-soroban-client

We attempt to keep the code in these generated libraries readable, so go ahead and look around. Open up the new hello-soroban-client 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. The README is a great place to start. Go ahead and give it a read.

As it says, when using local libraries, we've had the most success when generating them directly into the node_modules folder, and leaving them out of the dependencies section. Yes, this is surprising, but it works the best.

Let's update the package.json in your soroban-tutorial project with a postinstall script to make sure the generated library stays up-to-date:

package.json
  "scripts": {
...
- "astro": "astro"
+ "astro": "astro",
+ "postinstall": "soroban contract bindings typescript --network testnet --contract-id $(cat .soroban/hello-id) --output-dir node_modules/hello-soroban-client"
}

Call the contract from the frontend

Now let's open up src/pages/index.astro and add some code to call the contract. We'll start by importing the generated library:

src/pages/index.astro
 ---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
+import { Contract, networks } from 'hello-soroban-client';
+
+const greeter = new Contract({
+ ...networks.testnet,
+ rpcUrl: 'https://soroban-testnet.stellar.org', // from https://soroban.stellar.org/docs/reference/rpc#public-rpc-providers
+});
+
+const { result } = await greeter.hello({ to: 'Soroban' });
---

Then find the <h1> tag and replace its contents with the greeting:

src/pages/index.astro
-<h1>Welcome to <span class="text-gradient">Astro</span></h1>
+<h1><span class="text-gradient">{result.join(' ')}</span></h1>

Now start the dev server:

npm run dev

And open http://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.

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.

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 Freighter

We're going to add a "Connect" button to the page that opens Freighter and prompts the user to give your web page permission to use Freighter. Once they grant this permission, the "Connect" button will be replaced with a message saying, "Signed in as [their public key]".

First, add @stellar/freighter-api as a dependency:

npm install @stellar/freighter-api

Then we need to work around a bug in NPM—adding a new dependency with npm install [new dependency] doesn't run the postinstall hook, the way that npm install does. But it does run the cleanup logic that removes "incorrect" folders like node_modules/hello-soroban-client. So you either need to run npm i (a shortcut for npm install), or postinstall directly:

npm run postinstall

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

src/components/ConnectFreighter.astro
<div id="freighter-wrap" class="wrap" aria-live="polite">
<div class="ellipsis">
<button data-connect aria-controls="freighter-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 { isAllowed, setAllowed, getUserInfo } from '@stellar/freighter-api';

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

async function getPk() {
const { publicKey } = await getUserInfo();
return publicKey;
}

async function setLoggedIn() {
const publicKey = await getPk();
ellipsis.innerHTML = `Signed in as ${publicKey}`;
ellipsis.title = publicKey;
}

if (await isAllowed()) {
if (await getPk()) setLoggedIn();
else wrap.innerHTML = 'Freighter is locked.<br>Sign in & refresh the page.';
} else {
button.addEventListener('click', async () => {
button.disabled = true;
await setAllowed();
await setLoggedIn();
});
}
</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 a few methods from @stellar/freighter-api to check if the user is logged in. If they already are, then isAllowed returns true. If it's been more than a day since they've used the Freighter extension, then the publicKey will be blank, so we tell them to unlock Freighter and refresh the page. If isAllowed and the publicKey both look good, we replace the contents of the div with the signed-in message, replacing the button. Otherwise, we add a click handler to the button to prompt the user to connect Freighter with setAllowed. Once they do, we again replace the contents of the div with the signed-in message. The aria stuff ensures that screen readers will read the new contents when they're updated.

Before we add this to our index page, let's make the buttons look better. Open layouts/Layout.astro and look for the <style> tag. You'll see this one has an is:global attribute, which tells Astro to treat it as normal CSS, rather than scoping it to only the current component. That's exactly what we want for buttons. Paste these styles in there:

layouts/Layout.astro
button {
border: 1px solid rgb(var(--accent));
background-color: #23262d;
background-image: none;
background-size: 400%;
border-radius: 7px;
color: white;
cursor: pointer;
font-size: inherit;
padding: 0.5rem 1rem;
background-position: 100%;
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
button:is(:hover, :focus-within) {
color: black;
background-position: 0;
background-size: 400%;
background-image: var(--accent-gradient);
}
button:is(:disabled) {
color: white;
background: var(--accent-light);
cursor: not-allowed;
}

This copies the styles from the Card components that Astro included in the template.

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 ConnectFreighter from '../components/ConnectFreighter.astro';
import { Contract, networks } from 'hello-soroban-client';
...

And add it right below the <h1>:

pages/index.astro
 <h1><span class="text-gradient">{greeting.join(' ')}</span></h1>
+<ConnectFreighter />

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

We're going to generate a contract client for the incrementor contract with a similar command to the one we used before. Let's move the hello bindings generation to its own script, add one for incrementor, and call them both from postinstall using a double ampersand (&&):

package.json
"bindings:hello": "soroban contract bindings typescript --network testnet --contract-id $(cat .soroban/hello-id) --output-dir node_modules/hello-soroban-client",
"bindings:incrementor": "soroban contract bindings typescript --network testnet --contract-id $(cat .soroban/incrementor-id) --output-dir node_modules/incrementor-client",
"postinstall": "npm run bindings:hello && npm run bindings:incrementor"

Now reinstall dependencies to also run postinstall:

npm i

Now we can import from incrementor-client 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 { Contract, networks } from "incrementor-client";

const incrementor = new Contract({
...networks.testnet,
rpcUrl: "https://soroban-testnet.stellar.org", // from https://soroban.stellar.org/docs/reference/rpc-list#sdf-futurenet-and-testnet-only
});

const button = document.querySelector("[data-increment]");
const currentValue = document.querySelector("#current-value");

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

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;

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.

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.

Let's add styles for visually-hidden and loading class. In layouts/Layout.astro, add the following to the end of the style tag:

layouts/Layout.astro
button:is(:disabled).loading {
background: linear-gradient(-45deg, #ffffff44, #ffffff22);
background-size: 200%;
animation: loading-gradient 4s linear infinite;
}
@keyframes loading-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: -200% 50%;
}
}
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}

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 ConnectFreighter from '../components/ConnectFreighter.astro';
+import Counter from '../components/Counter.astro';
import { Contract, networks } from "hello-soroban-client";
...

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 incrementor 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.

Streamline the dev process with some script cleanup

Right now, the postinstall script assumes that you already have a .soroban directory with contract IDs inside. When other collaborators try to help you out, this will be frustrating. You can harness the scripts section in the package.json to make this project easier to work with. This will also make it easier for you to make changes to the contracts and then work with those changes in the frontend.

First, let's add a clean script that removes the .soroban directory to make it easy to go back to how collaborators will experience this repository when they first clone it. We'll also remove the node_modules/.vite directory, which is where Astro caches its build artifacts (Vite is a build tool used by Astro). Add the following to the scripts section of package.json:

package.json
"scripts": {
"clean": "rm -rf .soroban node_modules/.vite",
...
}

Next, let's add a setup script that builds your contracts, then checks if the .soroban folder is present, and if not, creates and funds the alice identity and deploys your contracts. This is a lot for a single NPM script, so we've broken it into a few to make it a little more legible. Add the following to the scripts section of package.json:

package.json
  "scripts": {
...
"create_deployer": "soroban keys generate alice && soroban keys fund alice --network testnet",
"deploy:hello": "soroban contract deploy --wasm target/wasm32-unknown-unknown/release/hello_soroban.wasm --source alice --network testnet > .soroban/hello-id",
"deploy:incrementor": "soroban contract deploy --wasm target/wasm32-unknown-unknown/release/incrementor.wasm --source alice --network testnet > .soroban/incrementor-id;",
"deploy": "npm run deploy:hello && npm run deploy:incrementor",
"setup": "soroban contract build && if [ ! -d .soroban ]; then npm run create_deployer && npm run deploy; fi",
...
}

Finally, let's add a reset that calls both in order:

package.json
"scripts": {
...
"reset": "npm run clean && npm run setup",
...
}

Now anytime you make changes to your contracts, you can run npm run reset to build the new contracts, deploy them, and install dependencies. And, of course, installing dependencies will run postinstall, which will also regenerate the contract client libraries directly to the node_modules folder.

You can also update the postinstall script to check for the existence of .soroban and automatically npm run setup if it's not found:

package.json
  "postinstall": "if [ ! -d .soroban ]; then npm run setup; fi && npm run bindings:hello && npm run bindings:incrementor",

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 incrementor contract, and use it to display the current value in the Counter component. (Remember to run npm run reset after you make changes to the contract!)
  • Add a "Decrement" button to the Counter component. This will again require a change to the incrementor contract, and another npm run reset.
  • Deploy your frontend. You can do this quickly and for free with GitHub. If you get stuck installing soroban-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 reset.

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

Looking at git diff will be a great way to remember all the interesting things we did in this step. Some of the things we did:

  • 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 Soroban contracts from JavaScript by generating client libraries using soroban contract bindings typescript
  • We learned about wallets and Freighter

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