> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lumera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a browser app

> Build a Vite and TypeScript single page app that stores files on Cascade through Keplr.

In this guide you build a Vite and TypeScript single page app that connects to the Keplr wallet, uploads files to Cascade, and retrieves them again by action ID. Cascade is the permanent storage service of Lumera Protocol. You pay once at upload time and the file stays available forever.

Every upload returns an action ID. This is the permanent on-chain reference you later use to download the file.

The app talks to Cascade straight from the browser. The SDK handles the SN-API transfer, so you do not need a backend server for storage.

<img className="block mx-auto dark:hidden" alt="Browser-first Cascade architecture" src="https://mintcdn.com/lumeraprotocol/AZkbYSakLFkCUc8N/images/diagrams/node-architecture-light.svg?fit=max&auto=format&n=AZkbYSakLFkCUc8N&q=85&s=cba6cfd4bd67ccec098c4476d756a151" width="720" height="255" data-path="images/diagrams/node-architecture-light.svg" />

<img className="mx-auto hidden dark:block" alt="Browser-first Cascade architecture" src="https://mintcdn.com/lumeraprotocol/AZkbYSakLFkCUc8N/images/diagrams/node-architecture-dark.svg?fit=max&auto=format&n=AZkbYSakLFkCUc8N&q=85&s=cd2c7aebc30150f287858ab738a38ed6" width="720" height="255" data-path="images/diagrams/node-architecture-dark.svg" />

## What you need

* [Node.js](https://nodejs.org/) 18 or later
* The [Keplr](https://www.keplr.app/) browser extension
* Testnet LUME for fees. Request tokens from the [faucet](/faucet).

## Build the app

<Steps>
  <Step title="Scaffold the project">
    Create a new Vite project and install the SDK together with the browser build tooling.

    ```bash theme={null}
    npm create vite@latest my-cascade-app -- --template vanilla-ts
    cd my-cascade-app
    npm install @lumera-protocol/sdk-js @cosmjs/proto-signing @cosmjs/stargate
    npm install -D vite-plugin-node-polyfills vite-plugin-wasm vite-plugin-top-level-await
    ```
  </Step>

  <Step title="Configure Vite">
    The SDK depends on WASM modules and Node.js globals that need polyfilling in the browser.

    ```ts vite.config.ts theme={null}
    import { defineConfig } from "vite";
    import { nodePolyfills } from "vite-plugin-node-polyfills";
    import wasm from "vite-plugin-wasm";
    import topLevelAwait from "vite-plugin-top-level-await";

    export default defineConfig({
      plugins: [
        wasm(),
        topLevelAwait(),
        nodePolyfills({
          include: ["buffer", "process"],
          globals: { Buffer: true, global: true, process: true },
        }),
      ],
      optimizeDeps: {
        exclude: ["undici", "@bokuweb/zstd-wasm"],
      },
      define: {
        "process.env": "{}",
      },
    });
    ```
  </Step>

  <Step title="Add the chain configuration">
    This file holds the testnet chain ID, the gas price, and the chain record the app suggests to Keplr.

    ```ts src/config.ts theme={null}
    export const CHAIN_ID = "lumera-testnet-2";
    export const GAS_PRICE = "0.025ulume";
    export const CHAIN_INFO = {
      chainId: CHAIN_ID,
      chainName: "Lumera Testnet",
      rpc: "https://rpc.testnet.lumera.io",
      rest: "https://lcd.testnet.lumera.io",
      bip44: { coinType: 60 },
      bech32Config: {
        bech32PrefixAccAddr: "lumera",
        bech32PrefixAccPub: "lumerapub",
        bech32PrefixValAddr: "lumeravaloper",
        bech32PrefixValPub: "lumeravaloperpub",
        bech32PrefixConsAddr: "lumeravalcons",
        bech32PrefixConsPub: "lumeravalconspub",
      },
      currencies: [
        { coinDenom: "LUME", coinMinimalDenom: "ulume", coinDecimals: 6 },
      ],
      feeCurrencies: [
        {
          coinDenom: "LUME",
          coinMinimalDenom: "ulume",
          coinDecimals: 6,
          gasPriceStep: { low: 0.025, average: 0.03, high: 0.04 },
        },
      ],
      stakeCurrency: {
        coinDenom: "LUME",
        coinMinimalDenom: "ulume",
        coinDecimals: 6,
      },
      features: ["stargate", "ibc-transfer", "eth-address-gen", "eth-key-sign"],
    };
    ```
  </Step>

  <Step title="Connect the Keplr wallet">
    `experimentalSuggestChain` registers the Lumera testnet in Keplr the first time a user connects.

    ```ts src/wallet.ts theme={null}
    import { CHAIN_ID, CHAIN_INFO } from "./config";

    export async function connectKeplr(): Promise<{
      address: string;
      signer: any;
    }> {
      if (!window.keplr) {
        throw new Error("Keplr extension not found. Please install Keplr.");
      }

      // Suggest the Lumera chain to Keplr
      await window.keplr.experimentalSuggestChain(CHAIN_INFO);
      await window.keplr.enable(CHAIN_ID);

      const offlineSigner = await window.keplr.getOfflineSignerAuto(CHAIN_ID);
      const accounts = await offlineSigner.getAccounts();

      return {
        address: accounts[0].address,
        signer: offlineSigner,
      };
    }
    ```
  </Step>

  <Step title="Create the Cascade client">
    The `getKeplrSigner` helper wraps the Keplr signer with the ADR-036 `signArbitrary` support that Cascade uses to authenticate storage operations. ADR-036 is the Cosmos standard for signing arbitrary off-chain messages.

    ```ts src/cascade.ts theme={null}
    import { createLumeraClient, getKeplrSigner } from "@lumera-protocol/sdk-js";
    import { CHAIN_ID, GAS_PRICE } from "./config";

    let client: Awaited<ReturnType<typeof createLumeraClient>> | null = null;

    export async function initCascade(address: string) {
      const signer = await getKeplrSigner(CHAIN_ID);

      client = await createLumeraClient({
        preset: "testnet",
        signer,
        address,
        gasPrice: GAS_PRICE,
      });

      return client;
    }

    export async function uploadFile(
      file: File,
      onProgress?: (status: string) => void
    ): Promise<string> {
      if (!client) throw new Error("Client not initialized");

      onProgress?.("Reading file...");
      const fileBytes = new Uint8Array(await file.arrayBuffer());

      onProgress?.("Registering action on-chain...");
      const result = await client.Cascade.uploader.uploadFile(fileBytes, {
        fileName: file.name,
        isPublic: true,
        taskOptions: {
          pollInterval: 2000,
          timeout: 300000,
        },
      });

      const actionId =
        (result as any).action_id || result.taskId || `file-${Date.now()}`;

      onProgress?.(`Upload complete! Action ID: ${actionId}`);
      return actionId;
    }

    export async function downloadFile(actionId: string): Promise<Uint8Array> {
      if (!client) throw new Error("Client not initialized");

      const stream = await client.Cascade.downloader.download(actionId);
      const reader = stream.getReader();
      const chunks: Uint8Array[] = [];

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        if (value) chunks.push(value);
      }

      // Reassemble chunks
      const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
      const result = new Uint8Array(totalLength);
      let offset = 0;
      for (const chunk of chunks) {
        result.set(chunk, offset);
        offset += chunk.length;
      }

      return result;
    }
    ```
  </Step>

  <Step title="Wire up the UI">
    The page has three controls. A connect button, an upload button with a file picker, and a download button with an action ID field.

    ```ts src/main.ts theme={null}
    import { connectKeplr } from "./wallet";
    import { initCascade, uploadFile, downloadFile } from "./cascade";

    const connectBtn = document.getElementById("connect") as HTMLButtonElement;
    const uploadBtn = document.getElementById("upload") as HTMLButtonElement;
    const downloadBtn = document.getElementById("download") as HTMLButtonElement;
    const fileInput = document.getElementById("file") as HTMLInputElement;
    const actionIdInput = document.getElementById("actionId") as HTMLInputElement;
    const status = document.getElementById("status") as HTMLDivElement;

    let address: string | null = null;

    connectBtn.addEventListener("click", async () => {
      try {
        const wallet = await connectKeplr();
        address = wallet.address;
        await initCascade(address);
        status.textContent = `Connected: ${address}`;
        uploadBtn.disabled = false;
        downloadBtn.disabled = false;
      } catch (err: any) {
        status.textContent = `Error: ${err.message}`;
      }
    });

    uploadBtn.addEventListener("click", async () => {
      const file = fileInput.files?.[0];
      if (!file) {
        status.textContent = "Please select a file first.";
        return;
      }

      try {
        const actionId = await uploadFile(file, (s) => {
          status.textContent = s;
        });
        actionIdInput.value = actionId;
        status.textContent = `File stored permanently! Action ID: ${actionId}`;
      } catch (err: any) {
        status.textContent = `Upload error: ${err.message}`;
      }
    });

    downloadBtn.addEventListener("click", async () => {
      const actionId = actionIdInput.value.trim();
      if (!actionId) {
        status.textContent = "Enter an action ID to download.";
        return;
      }

      try {
        status.textContent = "Downloading...";
        const data = await downloadFile(actionId);
        status.textContent = `Downloaded ${data.length} bytes.`;

        // Trigger browser download
        const blob = new Blob([data]);
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = "cascade-file";
        a.click();
        URL.revokeObjectURL(url);
      } catch (err: any) {
        status.textContent = `Download error: ${err.message}`;
      }
    });
    ```

    ```html index.html theme={null}
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Cascade File Storage</title>
        <style>
          body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
          button { padding: 0.5rem 1rem; margin: 0.25rem; cursor: pointer; }
          input { padding: 0.5rem; margin: 0.25rem; width: 100%; box-sizing: border-box; }
          #status { margin-top: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 0.5rem; }
        </style>
      </head>
      <body>
        <h1>Cascade File Storage</h1>
        <button id="connect">Connect Keplr</button>
        <hr />
        <h2>Upload</h2>
        <input type="file" id="file" />
        <button id="upload" disabled>Upload to Cascade</button>
        <hr />
        <h2>Download</h2>
        <input type="text" id="actionId" placeholder="Enter action ID" />
        <button id="download" disabled>Download from Cascade</button>
        <div id="status">Not connected</div>
        <script type="module" src="/src/main.ts"></script>
      </body>
    </html>
    ```
  </Step>

  <Step title="Run the app">
    ```bash theme={null}
    npm run dev
    ```

    Open `http://localhost:5173`, connect Keplr, and upload a file. The returned action ID is your permanent reference. Use it to retrieve the file from anywhere, at any time.
  </Step>
</Steps>

## Key takeaways

1. Use `getKeplrSigner` or `getLeapSigner` from the SDK. They wrap the wallet extension signer with the ADR-036 `signArbitrary` support that Cascade requires.
2. The action ID is your permanent reference. Store it in your database, on chain, or wherever your application tracks state.
3. Uploads run in two phases. The SDK first registers the action on chain, then transfers the file to a SuperNode.
4. Downloads are streamed. Read from the `ReadableStream` so large files never sit fully in memory.

## Next steps

<CardGroup cols={2}>
  <Card title="Encrypted storage" icon="lock" href="/cascade/guides/encrypted-storage">
    Encrypt files client-side before uploading them to Cascade.
  </Card>

  <Card title="Research Archive example" icon="graduation-cap" href="/cascade/guides/research-archive">
    Explore a full publishing platform built on Cascade.
  </Card>

  <Card title="Node.js guide" icon="server" href="/cascade/guides/nodejs">
    Run the same SDK on a server with a programmatic signer.
  </Card>
</CardGroup>
