Application architecture
Applications deployed on ICP are composed of one or more smart contracts, called canisters. A canister contains code in the form of a WebAssembly (Wasm) module and state, i.e., the data stored in the canister.
The default architecture has two canisters:
Backend canister: Used to store the application’s data and to provide endpoints to access and modify the data. Backend canisters can be written in a variety of different languages (Motoko, Rust, TypeScript, and more) through the use of canister development kits (CDKs). Each CDK includes build scripts that compile backend code to an ICP-compatible Wasm module.
Frontend canister: Stores the web assets for the application’s user interface and interacts with the backend. The frontend canister contains a Wasm module called an “asset canister” that the web assets get uploaded to.

This page will cover default project structure, how to interact with a backend canister from the command line, and how a frontend canister communicates with a backend canister to create a full-stack application.
Project structure and files
A default application, such as the “Hello, world!” example available on ICP Ninja, has the following structure:
hello
├── README.md
├── dfx.json
├── package.json
├── src
│ ├── hello_backend
│ │ └── main.mo
│ └── hello_frontend
│ ├── index.html
│ ├── package.json
│ └── vite.config.js
dfx.json
: Defines the project’s structure, including how to build and deploy it.src/hello_backend/
: Contains the backend code. This example uses Motoko.src/hello_frontend/
: Contains the frontend assets.
dfx.json
The dfx.json
file in the root directory defines the canisters that make up the application and where their source code is located. The hello
project has two canisters, the backend in Motoko and an asset canister for the frontend:
{
"canisters": {
"hello_backend": { // Backend canister name
"main": "src/hello_backend/main.mo", // Backend canister source code file
"type": "motoko" // Canister language
},
"hello_frontend": { // Frontend canister name
"dependencies": ["backend"], // Frontend is dependent on the backend
"frontend": {
"entrypoint": "hello_frontend/index.html" // File that should be loaded as entrypoint of app
},
"source": ["hello_frontend"], // Directory for asset files
"type": "assets" // This canister will become an asset canister
}
},
"defaults": {
"build": {
"args": "",
"packtool": ""
}
},
"output_env_file": ".env",
"version": 1
}
Learn more about dfx.json.
Backend
The src/hello_backend/main.mo
file contains the backend source code written in Motoko:
actor HelloWorld {
stable var greeting : Text = "Hello, ";
// This update method stores the greeting prefix in stable memory.
public func setGreeting(prefix : Text) : async () {
greeting := prefix;
};
// This query method returns the currently persisted greeting with the given name.
public query func greet(name : Text) : async Text {
return greeting # name # "!";
};
};
Let’s breakdown what this code does:
The code first defines an actor named
HelloWorld
. An actor is an object that can hold state and interact with the world through message passing.Then, it defines a stable variable called
greeting
. Stable variables are used to store data that will persist across canister upgrades. Learn more about upgrading canisters.Next, an update method called
setGreeting
is defined. Update methods alter the state of the canister. This method specifically will update the text stored in thegreeting
variable.Lastly, a query method called
greet
is defined. Query methods are read-only operations and simply return data from the canister. They cannot alter the canister’s state. This query method will return the text stored ingreeting
, followed by the text passed to the method. This method’s syntax can be broken down and explained further:public
: Enables the method to be publicly callable by users or other canisters.query
: Defines the type of call the method accepts.greet
: The method's name.(name : Text) : async Text
: Indicates that this method expects an input of typeText
when called. It will be stored in the variablename
. The method returns aText
value to the caller in anasync
manner.
The method’s body returns Text
concatenating Hello ,
the input value name
, and an exclamation mark.
Calling the backend canister
To interact with the canister's greet method, you will need to deploy the canister, then make a call to it.
If you are using ICP Ninja, you can click the deploy button, and Ninja will return a URL that allows you to call the backend through a generic API interface.
If you are using the IC SDK, you can run the commands:
dfx deploy --playground
dfx canister call hello_backend --playground greet ICP
The parts of this command are:
dfx canister call
: Thedfx
command used to call canisters.hello_backend
: The canister's name.--playground
: The network the canister is deployed and running on.greet ICP
: The name of the method you want to call followed by the input you want to pass to it.
The canister will return the following output:
("Hello, ICP!")
The dfx canister call
command sent a call to the hello_backend
canister that is deployed to mainnet for free using the --playground
argument. The call was sent to the greet
method, which was given the input "ICP."
The backend canister processed the call and returned the response defined in the method.
Since the method is defined as a query
method, the canister simply returns data to the caller; it does not alter the canister's state.
In contrast, setGreeting
is an update call that does alter the canister’s state. Calling it will change the data stored in the greeting
variable, which will then result in the greet
function returning a different response when called.
Make a call to the setGreeting
function with the input “Hi, “
:
dfx canister call hello_world_backend --playground setGreeting "Hi, "
Then, call the greet
function again:
dfx canister call hello_world_backend --playground greet ICP
Observe the new value of the greeting
variable:
("Hi, ICP!")
Frontend
The frontend of an application is used to facilitate user interaction with the methods defined in the backend. It is made up of assets, most commonly HTML, CSS, and JavaScript.
On ICP, an application's frontend is created through these steps:
A developer creates frontend assets.
The project's
dfx.json
file defines the frontend canister and specifies it as"type": "asset"
.When the project is deployed,
dfx
deploys an implementation of the asset canister interface to the canister. It then adds an API client called the ICP JavaScript agent to the frontend assets and uploads all assets to the canister. The ICP JavaScript agent facilitates the communication between the frontend and the backend. In general, ICP agents are libraries for interacting with canisters' public interfaces.
Backend and frontend communication
The frontend canister is essentially a web server hosting a website. When a user loads the application, the web browser fetches the user interface from the frontend canister. Once the frontend is loaded into the browser, the user can interact with it, triggering messages to be sent to the backend.

For example, the src/hello_frontend/index.html
file defines a simple HTML page that embeds the JavaScript code used to communicate with the backend canister:
...
<script type="module">
// Import the backend actor
import { backend } from 'declarations/backend';
// Add an event listener to the form
document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
// Get the name from the input field
const name = document.getElementById('name').value.toString();
// Calling the method "greet" on the backend actor with the name
const greeting = await backend.greet(name);
// Display the greeting returned by the backend actor
document.getElementById('greeting').innerText = greeting;
});
</script>
...
In this script, first, the backend declarations hello_backend
are imported. Declaration files define the public methods of a canister and their input and output types. For this application, the declarations file will include the backend's setGreeting
and greet
methods and their Text
input and output types. Declaration files are generated by dfx
during the build process.
When the agent makes a call to the backend canister, it uses these declaration files to determine which public methods it can submit requests to. Then it will create and send the request containing the request type, canister ID, method name, and any input or arguments to be passed to the method.
In this example, the user interface includes a text input box and a button to submit the input. When the button is pressed, the ICP JavaScript agent sends a query
request to the hello_backend
canister's greet
method that includes the user's text input.
The backend processes the request, then responds to the agent with an outgoing message that includes the result of the method, in this case, "Hello, <name>!"