4. 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 their parent directory:
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
. Unfortunately, the version of bindings typescript
packaged with CLI v0.9.4 has some bugs, and the fix can't be released yet because the main
branch of the CLI is broken with futurenet. To work around this, we're going to install a pinned version of the CLI from a fixed-and-still-futurenet-compatible fork. Create a directory called .cargo
(with the dot; it's a hidden folder):
mkdir .cargo
Then add one file to it, config.toml
. Paste these contents into it:
[alias] # command aliases
install_soroban = "install --git https://github.com/AhaLabs/soroban-tools --branch smartdeploy --root ./target soroban-cli --debug"
Now install the pinned version by using the alias you just set up:
cargo install_soroban
This will take a couple minutes; it builds a local version from the smartdeploy
branch into target/bin/soroban
. Once it's done, check that it worked:
ls target/bin/soroban
And check the version info of this smartdeploy
install:
./target/bin/soroban --version
If you want, you can add ./target/bin/
to your PATH, so that anytime you're in a project with a locally-installed version of soroban
in its target/bin
directory, you'll automatically use it (rather than your global version) when you just type soroban
. For this project, let's also add a soroban
script in the root of the project that automatically installs the pinned version if it's not there, then uses it. This will make it easier for your collaborators to work with you. Create a file called just soroban
and paste the following contents:
#!/bin/bash
if [ ! -f ./target/bin/soroban ]; then
cargo install_soroban
fi
./target/bin/soroban "$@" # `$@` expands to all arguments passed to this script
Make sure it's executable:
chmod +x soroban
Ok, now you can finally generate that NPM package:
./soroban contract bindings typescript \
--network testnet \
--contract-id $(cat .soroban/hello-id) \
--output-dir 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. Go ahead and move this new library there:
mv hello-soroban-client node_modules
And then 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:
"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:
---
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 greeting = await greeter.hello({ to: 'Soroban' });
---
Then find the <h1>
tag and replace its contents with the greeting:
-<h1>Welcome to <span class="text-gradient">Astro</span></h1>
+<h1><span class="text-gradient">{greeting.join(' ')}</span></h1>
Now start the dev server:
npm run dev
And open http://localhost:3000 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's a Chrome extension. Go ahead and install it now.
Once it's installed, open it up, go to Settings (the gear icon) → Preferences and toggle the switch to Enable Experimental Mode. Then go back to its home screen and select "Future 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:
<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 = ``;
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:
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
:
---
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>
:
<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 this one, and call them both from postinstall
using a double ampersand (&&):
"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:
<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 { increment } from "incrementor-client";
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 newValue = await increment();
// Only use `innerHTML` with contract values you trust!
// Blindly using values from an untrusted contract opens your users to script injection attacks!
currentValue.innerHTML = newValue;
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.
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:
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:
---
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 { hello } from 'hello-soroban-client';
...
Then use it. Let's replace the contents of the instructions
paragraph with it:
<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
:
"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
:
"scripts": {
...
"create_deployer": "./soroban config identity generate alice && ./soroban config identity 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:
"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:
"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 agreeter.ts
and anincrementor.ts
. Move thenew Contract({ ... })
logic into those files. You may also want to extract therpcUrl
variable to asrc/contracts/utils.ts
file. - Add a
get_value
method to theincrementor
contract, and use it to display the current value in theCounter
component. (Remember to runnpm run reset
after you make changes to the contract!) - Add a "Decrement" button to the
Counter
component. This will again require a change to theincrementor
contract, and anothernpm 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 calljust
, 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 greeting
some other way to troubleshoot.
One of the common problems here is that the contract expires. 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
andstyle
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:
- See more complex example contracts in the Basic Tutorials and Advanced Tutorials sections.
- Learn more about the fundamentals and concepts of Soroban.
- Check out a more full-featured example app, which uses React rather than vanilla JavaScript and Next.js rather than Astro. This app also has a more complex setup & initialization process, with the option of using a locally-hosted RPC node.