Creating a Frontend
Lets create a frontend for our auction using React.
Now that we have successfully created a contract, it's time to build a frontend to provide a user-friendly interface for interacting with it. Up until now, we have been using the CLI to send transactions and view the contract's state. However, frontends offer a more intuitive way for end users to interact with the contract. They can display all the relevant information in one place, allow users to make calls with a simple button click, and only require a wallet as a prerequisite.
Frontend structure
From the repository root, navigate to the auction frontend.
cd frontends/01-frontend
Here we have a simple Next.js frontend that we'll walk through to understand the basics of creating a frontend for a NEAR smart contract.
For starters, let's take a look at how the code in the frontend is structured by doing a quick overview of the important files.
| File | Description |
|---|---|
| _app.js | Responsible for rendering the page, initiates the wallet object and adds it to global context |
| index.js | The main page where the project's components are loaded into and contains most of the logic for the application like viewing the state of the contract and logic for placing a bid |
| near.js | Contains the wallet class that has methods to interact with the wallet and blockchain |
| context.js | Holds the global context - the wallet object and the signed-in account ID - that can be accessed anywhere |
| config.js | Specifies the network, auction contract account ID, and RPC URL |
| utils/amounts.mjs | Converts token amounts between decimal strings and integer units without losing precision |
| Navigation.jsx | A component that contains a button to sign users in and out of wallets |
| Bid.jsx | A component allowing a user to make a bid |
| LastBid.jsx | A component that displays the highest bid and when the highest bid will next refresh |
| Timer.jsx | A component that shows how long till the auction is over, or, if over, displays a button to claim the auction and then states the auction is over |
To run the frontend, use Node.js 22.22.2 or newer and install its dependencies:
cp .env.example .env.local
npm ci
npm run dev
Specifying the contract
We have a config file that reads the network, auction contract account ID, and RPC URL from environment variables. Copy .env.example to .env.local and set NEXT_PUBLIC_AUCTION_CONTRACT to your deployed, initialized auction. Set NEXT_PUBLIC_NETWORK_ID to testnet or mainnet, and optionally set NEXT_PUBLIC_RPC_URL to use a different RPC provider.
cp .env.example .env.local
These NEXT_PUBLIC_ variables are included in the frontend at build time, so rebuild after changing them.
export const NetworkId = process.env.NEXT_PUBLIC_NETWORK_ID || 'testnet';
export const AUCTION_CONTRACT = process.env.NEXT_PUBLIC_AUCTION_CONTRACT || 'basic-auction-example.testnet';
export const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ||
(NetworkId === 'mainnet' ? 'https://free.rpc.fastnear.com' : 'https://test.rpc.fastnear.com');
Setting up wallets
To be able to fully interact with the contract - send bids and claim the auction - you'll need a wallet to sign transactions. Wallets securely store your private keys and allow you to sign transactions without exposing your private key to the frontend. We use @hot-labs/near-connect to let users choose and connect a wallet.
We abstract the wallet connection in our near.js file by exposing methods to complete various tasks. Feel free to explore the file to see how NearConnector restores a connected account and listens for sign-in and sign-out events.
The wallet object is initialized in the _app.js file and added to the global context along with the account that is signed in to make it easier to access anywhere in the application. The connector is loaded in the browser, and the effect removes its event listeners when it is cleaned up.
import { useEffect, useState } from 'react';
import '@/styles/globals.css';
import { NearContext } from '@/context';
import { Navigation } from '@/components/Navigation';
import { Wallet } from '@/wallets/near';
import { NetworkId } from '@/config';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const wallet = new Wallet({ networkId: NetworkId });
export default function MyApp({ Component, pageProps }) {
const [signedAccountId, setSignedAccountId] = useState('');
useEffect(() => {
let cancelled = false;
let cleanup;
wallet.startUp((accountId) => { if (!cancelled) setSignedAccountId(accountId); })
.then((dispose) => { if (cancelled) dispose(); else cleanup = dispose; })
.catch((error) => { if (!cancelled) toast.error(error.message); });
return () => { cancelled = true; cleanup?.(); };
}, []);
return (
<NearContext.Provider value={{ wallet, signedAccountId }}>
<Navigation />
<Component {...pageProps} />
<ToastContainer />
</NearContext.Provider>
);
}
import { createContext } from 'react';
/**
* @typedef NearContext
* @property {import('./wallets/near').Wallet} wallet Current wallet
* @property {string} signedAccountId The AccountId of the signed user
*/
/** @type {import ('react').Context<NearContext>} */
export const NearContext = createContext({
wallet: undefined,
signedAccountId: ''
});
[!TIP]
Access keys
On NEAR, in additional to normal full access keys, we have
function-call access keysthat are given to applications to allow them to signnon-payabletransactions on behalf of the user. This is so the wallet doesn't have to pop up for each non-critical transaction, improving the user experience. In this example, bids attach a deposit, so the user approves these transactions through their wallet.You can read further about NEAR's key model here.
We add a sign-in and sign-out button in the navigation component to call the respective methods in the near.js file.
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState, useContext } from 'react';
import { NearContext } from '@/context';
import NearLogo from '/public/near-logo.svg';
export const Navigation = () => {
const { signedAccountId, wallet } = useContext(NearContext);
const [action, setAction] = useState(() => { });
const [label, setLabel] = useState('Loading...');
useEffect(() => {
if (!wallet) return;
if (signedAccountId) {
setAction(() => wallet.signOut);
setLabel(`Logout ${signedAccountId}`);
} else {
setAction(() => wallet.signIn);
setLabel('Login');
}
}, [signedAccountId, wallet]);
return (
<nav className="navbar navbar-expand-lg">
<div className="container-fluid">
<Link href="/">
<Image priority src={NearLogo} alt="NEAR" width="30" height="24" className="d-inline-block align-text-top" />
</Link>
<div className='navbar-nav pt-1'>
<button className="btn btn-secondary" onClick={action} > {label} </button>
</div>
</div>
</nav>
);
};
signIn = async () => (await this.getConnector()).connect();
signOut = async () => {
const connector = await this.getConnector();
await connector.disconnect(await connector.wallet());
};
Displaying the highest bid
To get the highest bid from the auction and who made it we call get_highest_bid. This function returns the highest bid in yoctoNEAR as a string. We use formatUnits(highestBidData.bid, 24) to display it in NEAR. The helper uses integer arithmetic and returns a decimal string, preserving every yoctoNEAR.
useEffect(() => {
const getInfo = async () => {
const highestBidData = await wallet.viewMethod({
contractId: AUCTION_CONTRACT,
method: "get_highest_bid",
});
setHighestBid(formatUnits(highestBidData.bid, 24))
setHighestBidder(highestBidData.bidder)
viewMethod = ({ contractId, method, args = {} }) =>
this.provider.callFunction({ contractId, method, args });
In the wallet file, viewMethod uses JsonRpcProvider.callFunction from near-api-js to read the contract through the configured RPC provider. Reading state does not require a connected wallet. The default testnet endpoint is https://test.rpc.fastnear.com; there are many different providers available.
We then pass the information about the highest bidder into the LastBid component to display the bid amount and the bidder's account Id.
{!highestBidder ? <SkeletonBid /> : <LastBid lastBid={highestBid} highestBidder={highestBidder} lastUpdate={secondsRemaining}/>}
import styles from './LastBid.module.css';
const LastBid = ({lastBid, highestBidder, lastUpdate}) => {
return (
<div className={styles.priceSection}>
<div className={styles.detail}>
<span className={styles.currentPrice}>The last bid was {lastBid} $NEAR </span>
</div>
<span>Made by {highestBidder} </span>
<span>Refresh page in {lastUpdate}</span>
</div>
)
}
export default LastBid
Updating the highest bid
Someone else could have placed a higher bid since the page was loaded. To keep the display up to date, we fetch the contract information every 20 seconds using setInterval. A second interval updates the countdown until the next refresh, and both intervals are cleared when the component unmounts.
const intervalId = setInterval(() => {
getInfo().catch((err) => setError(err.message));
setSecondsRemaining(20);
}, 20000);
const countdownIntervalId = setInterval(() => {
setSecondsRemaining(prev => (prev === 1 ? 20 : prev - 1));
}, 1000);
return () => {
clearInterval(intervalId);
clearInterval(countdownIntervalId);
};
Auction end time
The contract stores the end time of the auction in nanoseconds since the Unix epoch (1 January 1970 00:00:00 UTC). The Timer component converts this value to milliseconds using BigInt, compares it with Date.now(), and displays the time left in days, hours, minutes, and seconds.
const [time, setTime] = useState(0);
useEffect(() => {
const update = () => setTime(Math.max(0, Number(BigInt(endTime) / 1_000_000n) - Date.now()));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [endTime]);
const formatTime = (time) => {
const allSeconds = Math.floor(time / 1000);
const days = Math.floor(allSeconds / (3600 * 24));
const hours = Math.floor((allSeconds % (3600 * 24)) / 3600);
const minutes = Math.floor((allSeconds % 3600) / 60);
const seconds = allSeconds % 60;
return { allSeconds, days, hours, minutes, seconds };
};
const { allSeconds, days, hours, minutes, seconds } = formatTime(time);
Making a bid
To make a bid we make a call to the contract using the bid function. We specify the deposit amount in yoctoNEAR which will be the bid amount. The input box takes the bid amount in NEAR as a decimal string. We use parseUnits(amount, 24) to convert it to a yoctoNEAR string without using JavaScript floating-point arithmetic. We also attach 300 Tgas to the transaction; unused gas is refunded.
Here, since the user is changing the state of the contract, not just viewing it, the user needs to sign the transaction. Thus the wallet will pop up displaying the transaction details.
const bid = async (amount) => {
const real_amount = parseUnits(amount, 24)
let response = await wallet.callMethod({
contractId: AUCTION_CONTRACT,
method: "bid",
deposit: real_amount,
args: {},
gas:"300000000000000"
})
window.location.reload();
return response
}
callMethod = async ({ contractId, method, args = {}, gas = '300000000000000', deposit = '0' }) => {
const selectedWallet = await (await this.getConnector()).wallet();
const outcome = await selectedWallet.signAndSendTransaction({
receiverId: contractId,
actions: [{ type: 'FunctionCall', params: { methodName: method, args, gas: String(gas), deposit: String(deposit) } }],
});
if (!outcome) throw new Error('Wallet returned no transaction result');
if (typeof outcome.status === 'object' && 'Failure' in outcome.status) {
throw new Error('Transaction failed');
}
return getTransactionLastResult(outcome);
};
Claiming the auction
Once the auction is over (the current time is greater than the end time) the auction can be claimed. At this point, the timer will be hidden and a button to claim the auction will be displayed. Once clicked the claim function will be called on the auction contract to send the highest bid in NEAR to the auctioneer. The page reloads after a successful bid or claim to show the updated state, and errors are displayed through notifications.
const claim = async () => {
let response = await wallet.callMethod({
contractId: AUCTION_CONTRACT,
method: "claim",
gas:"300000000000000"
})
window.location.reload();
return response
}
Conclusion
In this part of the tutorial, we have implemented a simple frontend for a NEAR contract. Along the way, you have learned how to use near-connect to sign the user in and out, how to view the contract’s state, how to sign and send transactions.
While we can see the highest bid, we may want to see the auction's bidding history. Since the contract only stores the most recent bid (to reduce storage costs), we need to use an indexer to pull historical data. In the next part of the tutorial, we'll look at querying historical data using an API endpoint.