Skip to main content

Simple Frontend with @onflow/kit

Building on the Counter contract you deployed in Step 1: Contract Interaction and Step 2: Local Development, this tutorial shows you how to create a simple Next.js frontend that interacts with the Counter smart contract deployed on your local Flow emulator. Instead of using FCL directly, you'll leverage @onflow/kit to simplify authentication, querying, transactions, and to display real-time transaction status updates using convenient React hooks.

Objectives

After finishing this guide, you will be able to:

  • Wrap your Next.js app with a Flow provider using @onflow/kit.
  • Read data from a Cadence smart contract (Counter) using kit's query hook.
  • Send a transaction to update the smart contract's state using kit's mutation hook.
  • Monitor a transaction's status in real time using kit's transaction hook.
  • Authenticate with the Flow blockchain using kit's built-in hooks and the local Dev Wallet.

Prerequisites

Setting Up the Next.js App

Follow these steps to set up your Next.js project and integrate @onflow/kit.

Step 1: Create a New Next.js App

Run the following command in your project directory:


_10
npx create-next-app@latest kit-app-quickstart

During setup, choose the following options:

  • Use TypeScript: Yes
  • Use src directory: Yes
  • Use App Router: Yes

This command creates a new Next.js project named kit-app-quickstart inside your current directory. We're generating the frontend in a subdirectory so we can next move it into our existing project structure from the previous steps (you can't create an app in a non-empty directory).

Step 2: Move the Next.js App Up a Directory

Move the contents of the kit-app-quickstart directory into your project root. You can use the gui in your editor, or the console.

warning

You'll want to consolidate both .gitignore files, keeping the contents of both in the file that ends up in the root.

On macOS/Linux:


_10
mv kit-app-quickstart/* .
_10
mv kit-app-quickstart/.* . # To move hidden files (e.g. .env.local)
_10
rm -r kit-app-quickstart

On Windows (PowerShell):


_10
Move-Item -Path .\kit-app-quickstart\* -Destination . -Force
_10
Move-Item -Path .\kit-app-quickstart\.* -Destination . -Force
_10
Remove-Item -Recurse -Force .\kit-app-quickstart

Note: When moving hidden files (those beginning with a dot) like .gitignore, be cautious not to overwrite any important files.

Step 3: Install @onflow/kit

Install the kit library in your project:


_10
npm install @onflow/kit

This library wraps FCL internally and exposes a set of hooks for authentication, querying, sending transactions, and tracking transaction status.

Configuring the Local Flow Emulator and Dev Wallet

warning

You should already have the Flow emulator running from the local development step. If it's not running, you can start it again — but note that restarting the emulator will clear all blockchain state, including any contracts deployed in Step 2: Local Development.

Start the Flow Emulator (if not already running)

Open a new terminal window in your project directory and run:


_10
flow emulator start

This will start the Flow emulator on http://localhost:8888. Make sure to keep it running in a separate terminal.

Start the Dev Wallet

In another terminal window, run:


_10
flow dev-wallet

This will start the Dev Wallet on http://localhost:8701, which you'll use for authentication during development.

Wrapping Your App with FlowProvider

@onflow/kit provides a FlowProvider component that sets up the Flow Client Library configuration. In Next.js using the App Router, add or update your src/app/layout.tsx as follows:


_28
"use client";
_28
_28
import { FlowProvider } from "@onflow/kit";
_28
import * as fcl from "@onflow/fcl";
_28
import flowJson from "../flow.client.json";
_28
_28
// Configure FCL for local emulator
_28
fcl.config({
_28
"accessNode.api": "http://localhost:8888", // Connect to local emulator
_28
"discovery.wallet": "http://localhost:8701/fcl/authn", // Dev wallet for local development
_28
"flow.network": "emulator", // Using local emulator network
_28
});
_28
_28
export default function RootLayout({
_28
children,
_28
}: {
_28
children: React.ReactNode
_28
}) {
_28
return (
_28
<html lang="en">
_28
<body>
_28
<FlowProvider flowJson={flowJson}>
_28
{children}
_28
</FlowProvider>
_28
</body>
_28
</html>
_28
)
_28
}

This configuration initializes the kit with your local emulator settings and maps contract addresses based on your flow.json file.

For more information on Discovery configurations, refer to the Wallet Discovery Guide.

Interacting With the Chain

Now that we've set our provider, lets start interacting with the chain.

Querying the Chain

First, use the kit's useFlowQuery hook to read the current counter value from the blockchain.


_18
import { useFlowQuery } from '@onflow/kit';
_18
_18
const { data, isLoading, error, refetch } = useFlowQuery({
_18
cadence: `
_18
import "Counter"
_18
import "NumberFormatter"
_18
_18
access(all)
_18
fun main(): String {
_18
let count: Int = Counter.getCount()
_18
let formattedCount = NumberFormatter.formatWithCommas(number: count)
_18
return formattedCount
_18
}
_18
`,
_18
query: { enabled: true },
_18
});
_18
_18
// Use the count data in your component as needed.

This script fetches the counter value, formats it via the NumberFormatter, and returns the formatted string.

info
  • Import Syntax: The imports (import "Counter" and import "NumberFormatter") don't include addresses because those are automatically resolved using the flow.json file configured in your FlowProvider. This keeps your Cadence scripts portable and environment-independent.
  • enabled Flag: This controls whether the query should run automatically. Set it to true to run on mount, or pass a condition (e.g. !!user?.addr) to delay execution until the user is available. This is useful for queries that depend on authentication or other asynchronous data.

Sending a Transaction

Next, use the kit's useFlowMutate hook to send a transaction that increments the counter.


_27
import { useFlowMutate } from '@onflow/kit';
_27
_27
const {
_27
mutate: increment,
_27
isPending: txPending,
_27
data: txId,
_27
error: txError,
_27
} = useFlowMutate();
_27
_27
const handleIncrement = () => {
_27
increment({
_27
cadence: `
_27
import "Counter"
_27
_27
transaction {
_27
prepare(acct: &Account) {
_27
// Authorization handled via wallet
_27
}
_27
execute {
_27
Counter.increment()
_27
let newCount = Counter.getCount()
_27
log("New count after incrementing: ".concat(newCount.toString()))
_27
}
_27
}
_27
`,
_27
});
_27
};

Explanation

This sends a Cadence transaction to the blockchain using the mutate function. The transaction imports the Counter contract and calls its increment function. Authorization is handled automatically by the connected wallet during the prepare phase. Once submitted, the returned txId can be used to track the transaction's status in real time.

Subscribing to Transaction Status

Use the kit's [useFlowTransactionStatus] hook to monitor and display the transaction status in real time.


_13
import { useFlowTransactionStatus } from '@onflow/kit';
_13
_13
const { transactionStatus, error: txStatusError } = useFlowTransactionStatus({
_13
id: txId || "",
_13
});
_13
_13
useEffect(() => {
_13
if (txId && transactionStatus?.status === 4) {
_13
refetch();
_13
}
_13
}, [transactionStatus?.status, txId, refetch]);
_13
_13
// You can then use transactionStatus (for example, its statusString) to show updates.

Explanation:

  • useFlowTransactionStatus(txId) subscribes to real-time updates about a transaction's lifecycle using the transaction ID.
  • transactionStatus.status is a numeric code representing the state of the transaction:
    • 0: Unknown – The transaction status is not yet known.
    • 1: Pending – The transaction has been submitted and is waiting to be included in a block.
    • 2: Finalized – The transaction has been included in a block, but not yet executed.
    • 3: Executed – The transaction code has run successfully, but the result has not yet been sealed.
    • 4: Sealed – The transaction is fully complete, included in a block, and now immutable on-chain.
  • We recommend calling refetch() when the status reaches 3 (Executed) to update your UI more quickly after the transaction runs, rather than waiting for sealing.
  • The statusString property gives a human-readable version of the current status you can display in the UI.

Waiting for Sealed provides full on-chain confirmation but can introduce a delay — especially in local or test environments. Since most transactions (like incrementing a counter) don't require strong finality guarantees, you can typically refetch data once the transaction reaches Executed for a faster, more responsive user experience.

However:

  • If you're dealing with critical state changes (e.g., token transfers or contract deployments), prefer waiting for Sealed.
  • For non-critical UI updates, Executed is usually safe and significantly improves perceived performance.

Integrating Authentication and Building the Complete UI

Finally, integrate the query, mutation, and transaction status hooks with authentication using useCurrentFlowUser. Combine all parts to build the complete page.


_251
// src/app/page.js
_251
_251
"use client";
_251
_251
import { useState, useEffect } from "react";
_251
import {
_251
useFlowQuery,
_251
useFlowMutate,
_251
useFlowTransactionStatus,
_251
useCurrentFlowUser,
_251
} from "@onflow/kit";
_251
_251
export default function Home() {
_251
const { user, authenticate, unauthenticate } = useCurrentFlowUser();
_251
const [lastTxId, setLastTxId] = useState<string>();
_251
_251
const { data, isLoading, error, refetch } = useFlowQuery({
_251
cadence: `
_251
import Counter from 0xf8d6e0586b0a20c7
_251
import NumberFormatter from 0xf8d6e0586b0a20c7
_251
_251
access(all)
_251
fun main(): String {
_251
let count: Int = Counter.getCount()
_251
let formattedCount = NumberFormatter.formatWithCommas(number: count)
_251
return formattedCount
_251
}
_251
`,
_251
query: { enabled: true },
_251
});
_251
_251
const {
_251
mutate: increment,
_251
isPending: txPending,
_251
data: txId,
_251
error: txError,
_251
} = useFlowMutate();
_251
_251
const { transactionStatus, error: txStatusError } = useFlowTransactionStatus({
_251
id: txId || "",
_251
});
_251
_251
useEffect(() => {
_251
if (txId && transactionStatus?.status === 4) {
_251
refetch();
_251
}
_251
}, [transactionStatus?.status, txId, refetch]);
_251
_251
const handleIncrement = () => {
_251
increment({
_251
cadence: `
_251
import Counter from 0xf8d6e0586b0a20c7
_251
_251
transaction {
_251
prepare(acct: &Account) {
_251
// Authorization handled via wallet
_251
}
_251
execute {
_251
Counter.increment()
_251
let newCount = Counter.getCount()
_251
log("New count after incrementing: ".concat(newCount.toString()))
_251
}
_251
}
_251
`,
_251
});
_251
};
_251
_251
return (
_251
<div style={{
_251
display: 'flex',
_251
flexDirection: 'column',
_251
alignItems: 'center',
_251
justifyContent: 'center',
_251
minHeight: '100vh',
_251
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
_251
backgroundColor: '#f0f4f8',
_251
padding: '20px'
_251
}}>
_251
<h1 style={{ marginBottom: '40px', color: '#333' }}>Flow Counter dApp</h1>
_251
_251
{isLoading ? (
_251
<div style={{
_251
fontSize: '24px',
_251
color: '#666',
_251
animation: 'pulse 1.5s ease-in-out infinite'
_251
}}>
_251
Loading count...
_251
</div>
_251
) : error ? (
_251
<p style={{ color: '#e74c3c', fontSize: '18px' }}>Error fetching count: {error.message}</p>
_251
) : (
_251
<div style={{
_251
textAlign: 'center',
_251
marginBottom: '40px'
_251
}}>
_251
<div style={{
_251
fontSize: '120px',
_251
fontWeight: 'bold',
_251
color: '#2c3e50',
_251
textShadow: '2px 2px 4px rgba(0,0,0,0.1)',
_251
margin: '20px 0',
_251
animation: 'fadeIn 0.5s ease-in'
_251
}}>
_251
{(data as string) || "0"}
_251
</div>
_251
<p style={{
_251
fontSize: '18px',
_251
color: '#7f8c8d',
_251
marginTop: '-10px'
_251
}}>
_251
Current Count
_251
</p>
_251
</div>
_251
)}
_251
_251
{user?.loggedIn ? (
_251
<div style={{ textAlign: 'center' }}>
_251
<p style={{
_251
fontSize: '14px',
_251
color: '#666',
_251
marginBottom: '20px',
_251
backgroundColor: '#e8f4fd',
_251
padding: '10px 20px',
_251
borderRadius: '20px',
_251
display: 'inline-block'
_251
}}>
_251
Connected: {user.addr}
_251
</p>
_251
_251
<div style={{ marginBottom: '20px' }}>
_251
<button
_251
onClick={handleIncrement}
_251
disabled={txPending}
_251
style={{
_251
fontSize: '20px',
_251
padding: '15px 40px',
_251
backgroundColor: txPending ? '#95a5a6' : '#3498db',
_251
color: 'white',
_251
border: 'none',
_251
borderRadius: '10px',
_251
cursor: txPending ? 'not-allowed' : 'pointer',
_251
transition: 'all 0.3s ease',
_251
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
_251
marginRight: '10px',
_251
fontWeight: 'bold'
_251
}}
_251
onMouseEnter={(e) => {
_251
if (!txPending) {
_251
e.currentTarget.style.backgroundColor = '#2980b9';
_251
e.currentTarget.style.transform = 'translateY(-2px)';
_251
e.currentTarget.style.boxShadow = '0 6px 8px rgba(0,0,0,0.15)';
_251
}
_251
}}
_251
onMouseLeave={(e) => {
_251
if (!txPending) {
_251
e.currentTarget.style.backgroundColor = '#3498db';
_251
e.currentTarget.style.transform = 'translateY(0)';
_251
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)';
_251
}
_251
}}
_251
>
_251
{txPending ? "⏳ Processing..." : "🚀 Increment Count"}
_251
</button>
_251
_251
<button
_251
onClick={unauthenticate}
_251
style={{
_251
fontSize: '16px',
_251
padding: '15px 30px',
_251
backgroundColor: '#e74c3c',
_251
color: 'white',
_251
border: 'none',
_251
borderRadius: '10px',
_251
cursor: 'pointer',
_251
transition: 'all 0.3s ease',
_251
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
_251
}}
_251
onMouseEnter={(e) => {
_251
e.currentTarget.style.backgroundColor = '#c0392b';
_251
e.currentTarget.style.transform = 'translateY(-2px)';
_251
e.currentTarget.style.boxShadow = '0 6px 8px rgba(0,0,0,0.15)';
_251
}}
_251
onMouseLeave={(e) => {
_251
e.currentTarget.style.backgroundColor = '#e74c3c';
_251
e.currentTarget.style.transform = 'translateY(0)';
_251
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)';
_251
}}
_251
>
_251
Log Out
_251
</button>
_251
</div>
_251
_251
{(transactionStatus?.statusString || txError) && (
_251
<div style={{
_251
marginTop: '20px',
_251
padding: '15px',
_251
backgroundColor: txError ? '#fee' : '#f0f9ff',
_251
borderRadius: '10px',
_251
maxWidth: '400px',
_251
margin: '20px auto'
_251
}}>
_251
{transactionStatus?.statusString && (
_251
<p style={{ margin: '5px 0', color: '#666' }}>
_251
📋 Status: <strong>{transactionStatus.statusString}</strong>
_251
</p>
_251
)}
_251
{txError && (
_251
<p style={{ margin: '5px 0', color: '#e74c3c' }}>
_251
⚠️ Error: {txError.message}
_251
</p>
_251
)}
_251
{txStatusError && (
_251
<p style={{ margin: '5px 0', color: '#e74c3c' }}>
_251
⚠️ Status Error: {txStatusError.message}
_251
</p>
_251
)}
_251
</div>
_251
)}
_251
</div>
_251
) : (
_251
<button
_251
onClick={authenticate}
_251
style={{
_251
fontSize: '20px',
_251
padding: '15px 40px',
_251
backgroundColor: '#27ae60',
_251
color: 'white',
_251
border: 'none',
_251
borderRadius: '10px',
_251
cursor: 'pointer',
_251
transition: 'all 0.3s ease',
_251
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
_251
fontWeight: 'bold'
_251
}}
_251
onMouseEnter={(e) => {
_251
e.currentTarget.style.backgroundColor = '#229954';
_251
e.currentTarget.style.transform = 'translateY(-2px)';
_251
e.currentTarget.style.boxShadow = '0 6px 8px rgba(0,0,0,0.15)';
_251
}}
_251
onMouseLeave={(e) => {
_251
e.currentTarget.style.backgroundColor = '#27ae60';
_251
e.currentTarget.style.transform = 'translateY(0)';
_251
e.currentTarget.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)';
_251
}}
_251
>
_251
🔐 Connect Wallet
_251
</button>
_251
)}
_251
</div>
_251
);
_251
}

In this complete page:

  • Step 1 queries the counter value.
  • Step 2 sends a transaction to increment the counter and stores the transaction ID.
  • Step 3 subscribes to transaction status updates using the stored transaction ID and uses a useEffect hook to automatically refetch the updated count when the transaction is sealed (status code 4).
  • Step 4 integrates authentication via useCurrentFlowUser and combines all the pieces into a single user interface.
tip

In this tutorial, we inlined Cadence code for simplicity. For real projects, we recommend storing Cadence in separate .cdc files, using the Cadence VSCode extension, and importing them with the flow-cadence-plugin for Next.js or Webpack projects.

Running the App

Start your development server:


_10
npm run dev

warning

If you have the Flow wallet browser extension installed, you might automatically log into the app. Normally this is desirable for your users, but you don't want to use it here.

Log out, and log back in selecting the Dev Wallet instead of the Flow Wallet.

warning

For your app to connect with contracts deployed on the emulator, you need to have completed Step 1: Contract Interaction and Step 2: Local Development.

Then visit http://localhost:3000 in your browser. You should see:

  • The current counter value displayed (formatted with commas using NumberFormatter).
  • A Log In button that launches the kit Discovery UI with your local Dev Wallet.
  • Once logged in, your account address appears with options to Log Out and Increment Count.
  • When you click Increment Count, the transaction is sent; its status updates are displayed in real time below the action buttons, and once the transaction is sealed, the updated count is automatically fetched.

Wrapping Up

By following these steps, you've built a simple Next.js dApp that interacts with a Flow smart contract using @onflow/kit. In this guide you learned how to:

  • Wrap your application in a FlowProvider to configure blockchain connectivity.
  • Use kit hooks such as useFlowQuery, useFlowMutate, useFlowTransactionStatus, and useCurrentFlowUser to manage authentication, query on-chain data, submit transactions, and monitor their status.
  • Integrate with the local Flow emulator and Dev Wallet for a fully functional development setup.

For additional details and advanced usage, refer to the @onflow/kit documentation and other Flow developer resources.