For AI agents: Documentation index at /llms.txt

Skip to content

Frontend frameworks

ICP hosts frontend applications in canisters: static files (HTML, CSS, JavaScript) deployed to the network and served with certified responses. Any framework that can produce a static build output works: React, Vue, Svelte, Next.js, and even game engines like Unity WebGL and Godot.

This guide shows you how to configure your framework’s build pipeline, wire up the ICP JavaScript SDK, and deploy it as a static site.

  • icp-cli installed: npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm
  • A backend canister deployed (or a static-only site with no backend)
  • Familiarity with hosting a static site

Every frontend framework integration follows the same pattern:

  1. Configure icp.yaml to point at your framework’s build output directory
  2. Optionally add a Vite plugin (@icp-sdk/bindgen) to generate typed canister bindings at build time
  3. Use @icp-sdk/core in your app to read canister IDs and the root key at runtime from the ic_env cookie the canister serves
  4. Deploy with icp deploy

The canister injects an ic_env cookie into every HTML response. This cookie carries the root key and any PUBLIC_CANISTER_ID:<name> environment variables you set: so your frontend never needs canister IDs baked into the build artifact.

The hello-world template uses React with Vite. It demonstrates the fullstack: backend canister, auto-generated TypeScript bindings, and a React frontend that reads canister IDs at runtime.

canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
build:
- npm install
- npm run generate --prefix app
- npm run build
dir: app/dist

The build array runs before the canister is deployed and its files uploaded. npm run generate regenerates TypeScript bindings from the backend .did file; npm run build runs Vite.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite";
// Change these values to match your local replica.
// The `icp network start` command prints the root key;
// the `icp deploy` command prints the backend canister ID.
const IC_ROOT_KEY_HEX = "<IC_ROOT_KEY_HEX>";
const BACKEND_CANISTER_ID = "<BACKEND_CANISTER_ID>";
export default defineConfig({
plugins: [
react(),
icpBindgen({
didFile: "../../backend/backend.did",
outDir: "./src/backend/api",
}),
],
server: {
headers: {
// Simulate the ic_env cookie that the canister injects in production.
"Set-Cookie": `ic_env=${encodeURIComponent(
`ic_root_key=${IC_ROOT_KEY_HEX}&PUBLIC_CANISTER_ID:backend=${BACKEND_CANISTER_ID}`
)}; SameSite=Lax;`,
},
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});

The icpBindgen Vite plugin regenerates TypeScript bindings whenever the .did file changes during development.

The server.headers block simulates the ic_env cookie during vite dev. In production, the canister injects this cookie automatically: your code reads it without any build-time environment variables.

Install the required packages:

Terminal window
npm install @icp-sdk/core
npm install -D @icp-sdk/bindgen @vitejs/plugin-react
import { getCanisterEnv } from "@icp-sdk/core/agent/canister-env";
import { createActor } from "./backend/api/backend";
interface CanisterEnv {
readonly "PUBLIC_CANISTER_ID:backend": string;
}
// Reads from the ic_env cookie injected by the canister (production)
// or the Set-Cookie header set in vite.config.ts (development).
const canisterEnv = getCanisterEnv<CanisterEnv>();
const canisterId = canisterEnv["PUBLIC_CANISTER_ID:backend"];
const actor = createActor(canisterId, {
agentOptions: {
// In production, use the root key from the ic_env cookie.
// In development (import.meta.env.DEV), fetch it from the local replica.
rootKey: !import.meta.env.DEV ? canisterEnv.IC_ROOT_KEY : undefined,
shouldFetchRootKey: import.meta.env.DEV,
},
});

The createActor function is generated by @icp-sdk/bindgen from your .did file. It returns a fully typed actor. See the JS SDK docs for the full HttpAgent and Actor API.

React apps use client-side routing. Without a fallback, refreshing on /about returns a 404. Add a _redirects file to your public/ directory so it ends up in dist/:

# Serve the app shell for any path with no matching file. The 200 makes this a
# rewrite, so the URL the visitor typed stays in the address bar.
/* /index.html 200

The canister adds no default headers, so declare the ones you want in a _headers file next to it:

/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/assets/*
Cache-Control: public, max-age=31536000, immutable

Link assets with absolute paths (/assets/app.js), never relative ones: under a /* rewrite a relative URL resolves against the client route, so the browser asks for /dashboard/assets/app.js and gets the HTML shell back.

See Redirects and rewrites and Custom headers for the full syntax, and Asset canister (legacy) if you are moving a project off .ic-assets.json5.

Vue and Vite follow the same pattern as React. The only difference is the Vite plugin:

Terminal window
npm install @icp-sdk/core
npm install -D @icp-sdk/bindgen @vitejs/plugin-vue
vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite";
export default defineConfig({
plugins: [
vue(),
icpBindgen({
didFile: "../backend/backend.did",
outDir: "./src/backend/api",
}),
],
server: {
proxy: {
"/api": { target: "http://127.0.0.1:8000", changeOrigin: true },
},
},
});

If your Vue app calls getCanisterEnv() to read canister IDs, add the same server.headers block from the React section to simulate the ic_env cookie during local development (otherwise getCanisterEnv() will throw because the cookie is absent. The icp.yaml configuration is the same as the React example) point dir at dist.

Authentication with Internet Identity is framework-agnostic. The @icp-sdk/auth package works the same way in React, Vue, Svelte, and Next.js static export mode. See the Internet Identity guide for integration steps.

For SvelteKit, you must configure static export mode before deploying. The canister serves static files and does not support server-side rendering.

Terminal window
npm install -D @sveltejs/adapter-static
svelte.config.js
import adapter from "@sveltejs/adapter-static";
export default {
kit: {
adapter: adapter({
pages: "build",
assets: "build",
fallback: "index.html", // enables SPA mode
}),
},
};
icp.yaml
canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
build:
- npm install
- npm run build
dir: build

For Svelte (without SvelteKit), Vite is the standard build tool. The vite.config.js setup is the same as Vue: swap @vitejs/plugin-vue for @sveltejs/vite-plugin-svelte.

Next.js requires static export mode. Server components, API routes, and getServerSideProps are not supported. The canister only serves static files.

Enable static export in your Next.js config:

next.config.js
const nextConfig = {
output: "export",
};
module.exports = nextConfig;

This outputs static files to the out/ directory.

icp.yaml
canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
build:
- npm install
- npm run build
dir: out

Game engines that export HTML5 or WebGL builds can be deployed without a backend canister. The build output is pre-generated in the export step of the engine: icp.yaml just copies the files into place.

Export your game from Unity Editor: File → Build Settings → WebGL → Build. This creates a folder with index.html, Build/, and TemplateData/.

icp.yaml
canisters:
- name: unity_webgl_template_assets
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
dir: dist
build:
- mkdir -p dist
- cp -r src/unity_webgl_template_assets/assets/* dist/
- cp -r src/unity_webgl_template_assets/src/* dist/

The build commands copy the Unity WebGL export into dist/. Point dir at that directory.

See the Unity WebGL example for the full project structure.

Export your game from Godot Editor: Project → Export → HTML5 → Export Project. This creates an index.html and supporting files.

icp.yaml
canisters:
- name: godot_html5_assets
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
dir: dist
build:
- mkdir -p dist
- cp -r src/godot_html5_assets/assets/* dist/
- cp -r src/godot_html5_assets/src/* dist/

See the Godot HTML5 example for the full project structure.

Both game engine templates deploy with standard icp-cli commands:

Terminal window
# Start local network
icp network start -d
# Deploy the frontend canister
icp deploy
# Access your game locally
# http://<canister-id>.localhost:8000

No Vite plugin or JS SDK integration is needed for game builds. The canister serves the pre-built HTML and JavaScript files directly.

For sites with no backend canister (portfolios, landing pages, documentation):

icp.yaml
canisters:
- name: frontend
recipe:
type: "@dfinity/static-site@v0.3.3"
configuration:
build:
- npm install
- npm run build
dir: dist

No JS SDK integration is needed. The canister serves your files, and you configure headers and caching in _headers.

See the React hosting example for a minimal static frontend without a backend canister.

Terminal window
# Start local network
icp network start -d
# Deploy all canisters
icp deploy
# Deploy to mainnet
icp deploy -e ic

After deployment, the URL depends on your canister ID:

EnvironmentURL
Localhttp://<canister-id>.localhost:8000
Mainnethttps://<canister-id>.icp.net

Get your canister ID with:

Terminal window
icp canister settings show frontend -i