How to Build a Time Capsule DApp on Rootstock: Complete Step-by-Step Tutorial with Next.js and Solidity Smart Contracts

Michael OjekunleMichael Ojekunle
28 min read

Hey there, blockchain enthusiasts! Ever thought about sending a message to your future self or sharing a secret that only reveals itself at the perfect moment? Well, today, we’re diving into the world of digital time capsules, blockchain-style. Imagine locking away a message, a photo, or even a secret recipe, only to have it unsealed on a special date, like your birthday or graduation. And here’s the twist: it’s all powered by Rootstock (RSK), a Bitcoin sidechain that brings the security of Bitcoin and the flexibility of Ethereum’s smart contracts together. With Rootstock’s low fees and rock-solid security, it’s the perfect place to build something timeless, literally.

In this guide, I’ll walk through building a Digital Time Capsule dApp on Rootstock. You’ll learn how to create a smart contract that locks messages until a future date, build a sleek NextJS frontend to interact with it, and deploy everything to the Rootstock Testnet. Whether you’re new to blockchain or looking to sharpen your skills, this project is a fun way to get hands-on with smart contracts, decentralized storage, and dApp development.

So, grab a coffee, fire up your code editor, and let’s start building something for the future!

What’s a Digital Time Capsule, Anyway?

Remember those school projects where you’d bury a box of trinkets and notes, only to dig it up years later? A digital time capsule is the 21st-century version, except instead of a shovel, you’re using the blockchain. Here’s how it works:

  • Create: Write a message, pick a future date, and decide if it’s public or private.

  • Lock: The smart contract seals it away on the blockchain. No peeking until the big day!

  • Unlock: When the date arrives, the message is revealed. Public capsules can be seen by anyone; private ones are just for you.

It’s like sending a letter to the future, with the blockchain ensuring it stays sealed and tamper-proof. And because we’re using Rootstock, you get Bitcoin’s security blanket and fees so low you won’t even notice them.

What You’ll Learn Today

By the end of this guide, you’ll know how to:

  • Write an optimized smart contract in Solidity.

  • Build a NextJS frontend that talks to the blockchain using thirdweb SDKs.

  • Deploy your dApp to the Rootstock Testnet.

  • Test and interact with your time capsule dApp like a pro.

And don’t worry, I’ll keep it beginner-friendly. If you’ve ever felt lost in blockchain tutorials, this one’s for you. I’ll break it down step-by-step, with plenty of code snippets and explanations.

Let’s Talk Tech: The Smart Contract

Before we jump into the frontend, let’s peek under the hood at the smart contract that powers our time capsules. It’s written in Solidity and packed with features like time-locking, privacy controls, and text attachments. But don’t worry, it’s simpler than it sounds.

Quickly, before looking into the contract, let’s set up our project

mkdir time-capsule-dapp
cd time-capsule-dapp
mkdir smart-contract
cd smart-contract
npx hardhat init

And yes(enter) all the way through, i.e., selecting a JavaScript project, and yes to other options.

Then, let’s install the required dependencies for this project, @openzeppelin/contracts and dotenv. Run the commands

npm i @openzeppelin/contracts
npm i --save-dev dotenv

The Contract’s Superpowers

  • Time-Locking: Capsules stay sealed until the unlock date you set.

  • Privacy Options: Choose if your capsule is public (viewable by anyone after unlock) or private (only you can see it).

  • Attachments: Add files like photos or documents via URLs or additional text.

  • Gas Efficiency: We’ve optimized it to keep costs low, even on a blockchain.

Here’s the full smart contract. Create a new file. contracts/TimeCapsule.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract TimeCapsule is ReentrancyGuard {
    struct Capsule {
        uint256 unlockTime;
        address creator;
        address opener;
        string message;
        string[] attachments;
        bool isPrivate;
        bool isOpened;
    }

    mapping(uint256 => Capsule) public capsules;
    uint256 public nextCapsuleId;
    mapping(address => uint256[]) public capsulesByCreator;

    event CapsuleCreated(uint256 indexed id, address indexed creator, uint256 unlockTime);
    event CapsuleOpened(uint256 indexed id, address indexed opener);

    function createCapsule(
        string memory _message,
        uint256 _daysInFuture,
        bool _isPrivate,
        string[] memory _attachments
    ) external returns (uint256) {
        require(_daysInFuture > 0, "Unlock date must be in future");
        require(bytes(_message).length <= 1000, "Message exceeds 1000 bytes");
        require(_attachments.length <= 5, "Max 5 attachments allowed");

        uint256 unlockTime = block.timestamp + (_daysInFuture * 1 days);
        uint256 id = nextCapsuleId;

        Capsule storage capsule = capsules[id];
        capsule.unlockTime = unlockTime;
        capsule.creator = msg.sender;
        capsule.message = _message;
        capsule.isPrivate = _isPrivate;
        capsule.isOpened = false;
        capsule.opener = address(0);
        for (uint256 i = 0; i < _attachments.length; i++) {
            capsule.attachments.push(_attachments[i]);
        }

        capsulesByCreator[msg.sender].push(id);
        nextCapsuleId += 1;

        emit CapsuleCreated(id, msg.sender, unlockTime);
        return id;
    }

    function openCapsule(uint256 _id) external {
        Capsule storage capsule = capsules[_id];
        require(block.timestamp >= capsule.unlockTime, "Capsule still locked");
        if (capsule.isPrivate) {
            require(msg.sender == capsule.creator, "Only creator can open private");
        }
        require(!capsule.isOpened, "Capsule already opened");

        capsule.isOpened = true;
        capsule.opener = msg.sender;
        emit CapsuleOpened(_id, msg.sender);
    }

    function viewCapsule(uint256 _id) external view returns (string memory message, string[] memory attachments) {
        Capsule memory capsule = capsules[_id];
        if (capsule.isPrivate) {
            require(msg.sender == capsule.creator, "Only creator can view private");
        } else {
            require(block.timestamp >= capsule.unlockTime, "Public capsule locked");
        }
        return (capsule.message, capsule.attachments);
    }

    function getCapsuleDetails(uint256 _id) external view returns (
        address creator,
        uint256 unlockTime,
        bool isPrivate,
        bool isOpened,
        address opener
    ) {
        Capsule memory capsule = capsules[_id];
        return (capsule.creator, capsule.unlockTime, capsule.isPrivate, capsule.isOpened, capsule.opener);
    }

    function getCapsulesByCreator(address _creator) external view returns (uint256[] memory) {
        return capsulesByCreator[_creator];
    }
}

How It Works: A Quick Tour

  • Creating a Capsule: Users input a message, choose how many days until it unlocks, decide if it’s private, and add up to 5 attachments (like photos or files). The contract checks that the unlock date is in the future and the message isn’t too long (1000 bytes max).

  • Opening a Capsule: Once the unlock date passes, anyone can open a public capsule, but private ones can only be opened by the creator. Opening marks the capsule as "opened" and logs who did it.

  • Viewing a Capsule: For public capsules, anyone can view the message and attachments after the unlock date. For private ones, only the creator can view them at any time.

We’ve also added some smart optimizations:

  • Input Limits: Messages are capped at 1000 bytes, and attachments at 5, to keep gas costs predictable.

  • Creator Tracking: A handy mapping lets users fetch all their capsules easily.

Deploying to Rootstock

Now our contract is ready, let’s deploy it to Rootstock’s Testnet.

Step 1: Configure Hardhat for Rootstock

In your hardhat.config.js, add the Rootstock Testnet configuration:

require("@nomicfoundation/hardhat-toolbox");
const dotenv = require("dotenv");
dotenv.config();

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.28",
  networks: {
    // for testnet
    rootstock: {
      url: process.env.ROOTSTOCK_TESTNET_RPC_URL,
      accounts: [process.env.WALLET_KEY],
    },
  },
  etherscan: {
    // Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
    apiKey: {
      rootstock: "123",
    },
    customChains: [
      {
        network: "rootstock",
        chainId: 31,
        urls: {
          apiURL: "https://rootstock-testnet.blockscout.com/api/",
          browserURL: "https://rootstock-testnet.blockscout.com/",
        },
      },
    ],
  },
  sourcify: {
    enabled: false,
  },
};

And in your .env file in the root directory of your hardhat project

WALLET_KEY=<your-private-key>
ROOTSTOCK_TESTNET_RPC_URL=<your-rootstock-testnet-rpc-url>

You can get your testnet RPC URL from Alchemy Dashboard, ensure you’ve selected testnet.

Alchemy dashboard to get your rootstock Testnet RPC URL

Step 3: Write the ignition deployment modules

Create a new file in ignition/modules/TimeCapsule.js:

const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules");

module.exports = buildModule("TimeCapsuleModule", (m) => {
  const TimeCapsule = m.contract("TimeCapsule");

  return { TimeCapsule };
});

Step 4: Deploy the Contract

Run the deployment script:

npx hardhat ignition deploy ./ignition/modules/TimeCapsule.js --network rootstock --verify

This will deploy your contract to the Rootstock Testnet.

Building the Frontend

Now, let’s bring this contract to life with a sleek frontend. We’ll use thirdweb to connect to the Rootstock Testnet, so users can create and view capsules right from their browser.

What You’ll Need

  • Node.js: Make sure it’s installed.

  • Contract Details: The address of your deployed TimeCapsule contract.

Step 1: Set Up the Project

In your time-capsule-dapp directory, run the command below

npx create-next-app@latest
// or use this if you have pnpm installed
pnpx create-next-app@latest

And as seen below, select the options, the name of the frontend app will be called client

You can follow this link to see how to install pnpm

Then let’s install some dependencies we’ll be using through this project first, and we’ll set up Shadcn

cd client 
npx shadcn@latest init 
// or if you have pnpm installed
pnpm dlx shadcn@latest init

And then install sonner and thirdweb

npm i thirdweb sonner --legacy-peer-deps
// or if you have pnpm installed
pnpm i thirdweb sonner

Now let’s set up thirdweb

Create a new file lib/config.ts

import { createThirdwebClient, defineChain } from "thirdweb";
import { inAppWallet, createWallet } from "thirdweb/wallets";

// Get client ID from environment variable
const clientId = process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID

if (!clientId) {
  console.warn(
    "⚠️ No thirdweb client ID found. Make sure NEXT_PUBLIC_THIRDWEB_CLIENT_ID is set in your environment variables.",
  )
}

export const client = createThirdwebClient({
  clientId: clientId || "",
})

export const rootstockTestnet = defineChain({
  id: 31,
  name: "Rootstock Testnet",
  rpc: "https://public-node.testnet.rsk.co",
  nativeCurrency: {
    name: "tRBTC",
    symbol: "tRBTC",
    decimals: 18,
  },
  blockExplorers: [
    { name: "RSK Testnet Explorer", url: "https://explorer.testnet.rootstock.io/" },
    { name: "Blockscout Testnet Explorer", url: "https://rootstock-testnet.blockscout.com/" },
  ],
  testnet: true,
});

export const wallets = [
  inAppWallet({
    auth: {
      options: [
        "google",
        "telegram",
        "farcaster",
        "email",
        "x",
        "passkey",
        "phone",
      ],
    },
    smartAccount: {
      chain: rootstockTestnet,
      sponsorGas: true,
    }
  }),
  createWallet("io.metamask"),
  createWallet("com.coinbase.wallet"),
  createWallet("io.rabby"),
];

Then add to your .env.local in the root of your client directory, your thirdweb-client-id. You can get it by heading to your thirdweb dashboard here, creating a new project, and getting the client ID for the project.

NEXT_PUBLIC_THIRDWEB_CLIENT_ID=<your-thirdweb-client-id>

And then in your layout.tsxWe’re going to wrap our App with the ThirdwebProvider and adding the toaster component here for notifications for users using our dapp

import type React from "react"
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import { ThirdwebProvider } from "thirdweb/react"
import { Toaster } from "sonner"

const inter = Inter({ subsets: ["latin"] })

export const metadata: Metadata = {
  title: "TimeCapsule DApp",
  description: "Create and discover time capsules on the blockchain",
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <ThirdwebProvider>
          {children}
          <Toaster position="top-right" />
        </ThirdwebProvider>
      </body>
    </html>
  )
}

And let’s use the Connect Button in our page.tsx file

"use client";

import { useActiveAccount, ConnectButton } from "thirdweb/react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { client } from "@/lib/config";

export default function HomePage() {
  const account = useActiveAccount();

  if (!account) {
    return (
      <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50 flex items-center justify-center p-4">
        <Card className="w-full max-w-md">
          <CardHeader className="text-center">
            <div className="mx-auto mb-4 w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
              <Package className="w-6 h-6 text-purple-600" />
            </div>
            <CardTitle className="text-2xl">TimeCapsule DApp</CardTitle>
            <CardDescription>
              Create and discover time capsules on the blockchain. Connect your
              wallet to get started.
            </CardDescription>
          </CardHeader>
          <CardContent className="text-center">
            <ConnectButton
              client={client}
              theme="light"
              connectModal={{
                size: "wide",
              }}
            />
          </CardContent>
        </Card>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50">
      <div className="text-3xl font-bold text-center">
        Connected: {account.address}
      </div>
    </div>
  );
}

You’d notice a typescript error here, complaining that “@components/ui/card” cannot be found, this Card component is part of the components we’ll be installing from the shadcn-ui library, so we’re gonna install all the components we’re going to be using once.

npx shadcn@latest add alert badge button card collapsible dialog dropdown-menu input label separator switch tabs textarea
// or if you have pnpm installed
pnpm dlx shadcn@latest add alert badge button card collapsible dialog dropdown-menu input label separator switch tabs textarea

Then you can run the application

npm run dev
or
pnpm dev

Then you should see these below

Now let’s start integrating our contract, First, we’ll set up our contract

Create a new file lib/contract.ts

import { getContract } from "thirdweb";
import { client, rootstockTestnet } from "./config";

const CONTRACT_ADDRESS = "0xD2322d9cc6Ee1A96aBfe43B46EEa26fd3B9C4133"; // replace this with your deployed contract address

if (!CONTRACT_ADDRESS) {
  console.warn(
    "⚠️ No contract address provided."
  );
}

export const contract = getContract({
  client,
  chain: rootstockTestnet,
  address: CONTRACT_ADDRESS,
});

Now, let’s start with creating capsules. Create a new file components/create-capsule-dialog.tsx.

"use client";

import type React from "react";

import { useState } from "react";
import { useSendAndConfirmTransaction } from "thirdweb/react";
import { prepareContractCall } from "thirdweb";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, X, Calendar, Lock, Globe } from "lucide-react";
import { toast } from "sonner";
import { contract } from "@/lib/contract";

interface CreateCapsuleDialogProps {
  isOpen: boolean;
  onClose: () => void;
}

export default function CreateCapsuleDialog({
  isOpen,
  onClose,
}: CreateCapsuleDialogProps) {
  const [message, setMessage] = useState("");
  const [daysInFuture, setDaysInFuture] = useState("");
  const [isPrivate, setIsPrivate] = useState(false);
  const [attachments, setAttachments] = useState<string[]>([]);
  const [newAttachment, setNewAttachment] = useState("");

  const { mutate: sendAndConfirmTransaction, isPending } =
    useSendAndConfirmTransaction();

  const addAttachment = () => {
    if (newAttachment.trim() && attachments.length < 5) {
      setAttachments([...attachments, newAttachment.trim()]);
      setNewAttachment("");
    }
  };

  const removeAttachment = (index: number) => {
    setAttachments(attachments.filter((_, i) => i !== index));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!message.trim() || !daysInFuture) {
      toast.error("Please fill in all required fields");
      return;
    }

    if (Number.parseInt(daysInFuture) <= 0) {
      toast.error("Days in future must be greater than 0");
      return;
    }

    try {
      const transaction = prepareContractCall({
        contract,
        method:
          "function createCapsule(string memory _message, uint256 _daysInFuture, bool _isPrivate, string[] memory _attachments) returns (uint256)",
        params: [message, BigInt(daysInFuture), isPrivate, attachments],
      });

      sendAndConfirmTransaction(transaction, {
        onSuccess: () => {
          toast.success("Time capsule created successfully!");
          // Reset form
          setMessage("");
          setDaysInFuture("");
          setIsPrivate(false);
          setAttachments([]);
          setNewAttachment("");
          onClose();
        },
        onError: (error) => {
          console.error("Error creating capsule:", error);
          toast.error("Failed to create time capsule");
        },
      });
    } catch (error) {
      console.error("Error preparing transaction:", error);
      toast.error("Failed to prepare transaction");
    }
  };

  const unlockDate = daysInFuture
    ? new Date(
        Date.now() + Number.parseInt(daysInFuture) * 24 * 60 * 60 * 1000
      ).toLocaleDateString()
    : "";

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="w-full max-w-2xl max-h-[90vh] overflow-y-auto overflow-x-hidden">
        <DialogHeader>
          <DialogTitle className="flex items-center gap-2">
            <Calendar className="w-5 h-5 text-purple-600" />
            Create Time Capsule
          </DialogTitle>
          <DialogDescription>
            Create a message that will be unlocked in the future. Add
            attachments and choose privacy settings.
          </DialogDescription>
        </DialogHeader>

        <form onSubmit={handleSubmit} className="w-full space-y-6">
          {/* Message */}
          <div className="space-y-2">
            <Label htmlFor="message">Message *</Label>
            <Textarea
                id="message"
                placeholder="Write your message for the future..."
                value={message}
                onChange={(e) => setMessage(e.target.value)}
                maxLength={1000}
                rows={4}
                required
                style={{ width: "460px", height: "150px", minWidth: "400px", maxWidth: "640px", minHeight: "150px", maxHeight: "180px" }}
                className="resize-none"
            />
            <p className="text-sm text-gray-500">
              {message.length}/1000 characters
            </p>
          </div>

          {/* Days in Future */}
          <div className="space-y-2">
            <Label htmlFor="days">Days in Future *</Label>
            <Input
              id="days"
              type="number"
              placeholder="e.g., 365"
              value={daysInFuture}
              onChange={(e) => setDaysInFuture(e.target.value)}
              min="1"
              required
            />
            {unlockDate && (
              <p className="text-sm text-gray-500">
                Will unlock on: {unlockDate}
              </p>
            )}
          </div>

          {/* Privacy Setting */}
          <div className="flex items-center justify-between p-4 border rounded-lg">
            <div className="flex items-center gap-3">
              {isPrivate ? (
                <Lock className="w-5 h-5 text-gray-600" />
              ) : (
                <Globe className="w-5 h-5 text-gray-600" />
              )}
              <div>
                <Label htmlFor="privacy">Private Capsule</Label>
                <p className="text-sm text-gray-500">
                  {isPrivate
                    ? "Only you can open this capsule"
                    : "Anyone can open this capsule after unlock time"}
                </p>
              </div>
            </div>
            <Switch
              id="privacy"
              checked={isPrivate}
              onCheckedChange={setIsPrivate}
            />
          </div>

          {/* Attachments */}
          <div className="space-y-4">
            <Label>Attachments (Optional)</Label>
            <div className="flex gap-2">
              <Input
                placeholder="Add URL or text attachment"
                value={newAttachment}
                onChange={(e) => setNewAttachment(e.target.value)}
                onKeyPress={(e) =>
                  e.key === "Enter" && (e.preventDefault(), addAttachment())
                }
              />
              <Button
                type="button"
                onClick={addAttachment}
                disabled={!newAttachment.trim() || attachments.length >= 5}
                variant="outline"
              >
                <Plus className="w-4 h-4" />
              </Button>
            </div>

            {attachments.length > 0 && (
              <div className="space-y-2">
                {attachments.map((attachment, index) => (
                  <div
                    key={index}
                    className="flex items-center gap-2 p-2 bg-gray-50 rounded min-w-0"
                  >
                    <span className="flex-1 text-sm break-all overflow-hidden text-ellipsis min-w-0">
                      {attachment}
                    </span>
                    <Button
                      type="button"
                      onClick={() => removeAttachment(index)}
                      variant="ghost"
                      size="sm"
                      className="flex-shrink-0"
                    >
                      <X className="w-4 h-4" />
                    </Button>
                  </div>
                ))}
                <p className="text-sm text-gray-500">
                  {attachments.length}/5 attachments
                </p>
              </div>
            )}
          </div>

          {/* Preview Card */}
          {(message || daysInFuture) && (
            <Card className="bg-purple-50 border-purple-200 w-[460px]">
              <CardContent className="p-4">
                <h4 className="font-medium mb-2 flex items-center gap-2">
                  Preview
                  <Badge variant={isPrivate ? "secondary" : "outline"}>
                    {isPrivate ? "Private" : "Public"}
                  </Badge>
                </h4>
                <p className="text-sm text-gray-600 mb-2 break-words">
                  {message || "Your message will appear here..."}
                </p>
                {unlockDate && (
                  <p className="text-xs text-purple-600">
                    Unlocks: {unlockDate}
                  </p>
                )}
              </CardContent>
            </Card>
          )}

          {/* Actions */}
          <div className="flex gap-3 pt-4">
            <Button
              type="button"
              variant="outline"
              onClick={onClose}
              className="flex-1"
            >
              Cancel
            </Button>
            <Button
              type="submit"
              disabled={isPending || !message.trim() || !daysInFuture}
              className="flex-1 bg-purple-600 hover:bg-purple-700"
            >
              {isPending ? "Creating..." : "Create Capsule"}
            </Button>
          </div>
        </form>
      </DialogContent>
    </Dialog>
  );
}

What is happening above here is a lot of form validations from the frontend before sending over the data to the contract, proper user feedback using loading and error states, and clearing the form after submission, and other UI related, Let’s quickly look at what’s happening here and how does this component interacts with our contract using thirdweb.

First, we’re using the useSendAndConfirmTransaction hook

const { mutate: sendAndConfirmTransaction, isPending } = useSendAndConfirmTransaction();

From which we get the sendAndConfirmTransaction function we’ll be using to call the function in our contract, and then the isPending parameter that we can use to track the status of the transaction

Then, in the handleSubmit function

const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!message.trim() || !daysInFuture) {
      toast.error("Please fill in all required fields");
      return;
    }

    if (Number.parseInt(daysInFuture) <= 0) {
      toast.error("Days in future must be greater than 0");
      return;
    }

    try {
      const transaction = prepareContractCall({
        contract,
        method:
          "function createCapsule(string memory _message, uint256 _daysInFuture, bool _isPrivate, string[] memory _attachments) returns (uint256)",
        params: [message, BigInt(daysInFuture), isPrivate, attachments],
      });

      sendAndConfirmTransaction(transaction, {
        onSuccess: () => {
          toast.success("Time capsule created successfully!");
          // Reset form
          setMessage("");
          setDaysInFuture("");
          setIsPrivate(false);
          setAttachments([]);
          setNewAttachment("");
          onClose();
        },
        onError: (error) => {
          console.error("Error creating capsule:", error);
          toast.error("Failed to create time capsule");
        },
      });
    } catch (error) {
      console.error("Error preparing transaction:", error);
      toast.error("Failed to prepare transaction");
    }
  };

This prevents the form’s default behavior, and then we validate that all fields have been filled correctly. After the validation is complete, we then proceed to call the transaction using the sendAndConfirmTransaction function, which we pass a prepared contract call to using the prepareContractCall utility from thirdweb, the prepareContractCall function accepts the contract, method and params as shown below, and then we call the transaction by using sendAndConfirmTransaction, which sends and confirms the transaction, after which the onSuccess function is triggered if the transaction is successful, or the onError function is triggered if the transaction fails.

Now, let’s put this into our main page.tsx file, we’re going to update it with the following:

"use client";

import { useState } from "react";
import { useActiveAccount, useReadContract, ConnectButton } from "thirdweb/react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, Package } from "lucide-react";
import { client } from "@/lib/config";
import CreateCapsuleDialog from "@/components/create-capsule-dialog";
import GettingStarted from "@/components/getting-started";

export default function HomePage() {
  const account = useActiveAccount();
  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);

  if (!account) {
    return (
      <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50 flex items-center justify-center p-4">
        <Card className="w-full max-w-md">
          <CardHeader className="text-center">
            <div className="mx-auto mb-4 w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
              <Package className="w-6 h-6 text-purple-600" />
            </div>
            <CardTitle className="text-2xl">TimeCapsule DApp</CardTitle>
            <CardDescription>
              Create and discover time capsules on the blockchain. Connect your
              wallet to get started.
            </CardDescription>
          </CardHeader>
          <CardContent className="text-center">
            <ConnectButton
              client={client}
              theme="light"
              connectModal={{
                size: "wide",
              }}
            />
          </CardContent>
        </Card>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50">
      <div className="container mx-auto px-4 py-8">
        {/* Header */}
        <div className="flex items-center justify-between mb-8">
          <div>
            <h1 className="text-3xl font-bold text-gray-900 mb-2">
              TimeCapsule DApp
            </h1>
            <p className="text-gray-600">
              Create and discover time capsules on the blockchain
            </p>
          </div>
          <div className="flex items-center gap-4">
            <Button
              onClick={() => setIsCreateDialogOpen(true)}
              className="bg-purple-600 hover:bg-purple-700"
            >
              <Plus className="w-4 h-4 mr-2" />
              Create Capsule
            </Button>
            <ConnectButton
              client={client}
              theme="light"
              connectModal={{
                size: "compact",
              }}
            />
          </div>
        </div>

        {/* Getting Started */}
        <div className="space-y-4 mb-8">
          <GettingStarted onCreateCapsule={() => setIsCreateDialogOpen(true)} />
        </div>

        <CreateCapsuleDialog
          isOpen={isCreateDialogOpen}
          onClose={() => setIsCreateDialogOpen(false)}
        />
      </div>
    </div>
  );
}

Also very quickly, we’ll add a component to help new users navigate how to use our time-capsule-dapp, we’ll call it components/getting-started.tsx

"use client"

import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { ChevronDown, ChevronRight, Rocket, Package, Clock, Users } from "lucide-react"

interface GettingStartedProps {
  onCreateCapsule: () => void
}

export default function GettingStarted({ onCreateCapsule }: GettingStartedProps) {
  const [isOpen, setIsOpen] = useState(true)

  const features = [
    {
      icon: Package,
      title: "Create Time Capsules",
      description: "Write messages and add attachments to be unlocked in the future",
      color: "text-purple-600",
    },
    {
      icon: Clock,
      title: "Set Unlock Times",
      description: "Choose when your capsules will be available to open",
      color: "text-blue-600",
    },
    {
      icon: Users,
      title: "Privacy Controls",
      description: "Make capsules private (only you can open) or public (anyone can open)",
      color: "text-green-600",
    },
  ]

  return (
    <Card className="border-purple-200 bg-gradient-to-r from-purple-50 to-blue-50">
      <Collapsible open={isOpen} onOpenChange={setIsOpen}>
        <CollapsibleTrigger asChild>
          <CardHeader className="cursor-pointer hover:bg-white/50 transition-colors">
            <div className="flex items-center justify-between">
              <div className="flex items-center gap-2">
                <Rocket className="h-5 w-5 text-purple-600" />
                <CardTitle className="text-lg">Getting Started</CardTitle>
                <Badge variant="secondary" className="text-xs">
                  New
                </Badge>
              </div>
              {isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
            </div>
            <CardDescription>Learn how to use the TimeCapsule DApp</CardDescription>
          </CardHeader>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <CardContent className="space-y-6">
            <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
              {features.map((feature, index) => (
                <div key={index} className="flex items-start gap-3 p-3 bg-white/60 rounded-lg">
                  <feature.icon className={`h-5 w-5 ${feature.color} mt-0.5 flex-shrink-0`} />
                  <div>
                    <h3 className="font-medium text-sm">{feature.title}</h3>
                    <p className="text-xs text-gray-600 mt-1">{feature.description}</p>
                  </div>
                </div>
              ))}
            </div>

            <div className="bg-white/60 rounded-lg p-4">
              <h3 className="font-medium mb-2">Quick Start Guide</h3>
              <ol className="list-decimal list-inside space-y-1 text-sm text-gray-700">
                <li>Click &quot;Create Capsule&quot; to write your first message</li>
                <li>Set how many days in the future it should unlock</li>
                <li>Choose if it should be private or public</li>
                <li>Add any attachments (URLs, text, etc.)</li>
                <li>Submit the transaction to create your capsule</li>
                <li>Wait for the unlock time to open and view your message!</li>
              </ol>
            </div>

            <div className="flex gap-3">
              <Button onClick={onCreateCapsule} className="bg-purple-600 hover:bg-purple-700">
                <Package className="w-4 h-4 mr-2" />
                Create Your First Capsule
              </Button>
              <Button variant="outline" onClick={() => setIsOpen(false)}>
                Got it, thanks!
              </Button>
            </div>
          </CardContent>
        </CollapsibleContent>
      </Collapsible>
    </Card>
  )
}

Now, you’d see this on your local server

Let’s now add the component where we’ll view all capsules and the capsules the currently connected user has created.

First, we create the capsule card component: create a new file components/capsule-card.tsx

"use client";

import { useState } from "react";
import { useReadContract, useSendAndConfirmTransaction } from "thirdweb/react";
import { prepareContractCall } from "thirdweb";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Clock, Lock, Unlock, Eye, Calendar, User } from "lucide-react";
import { toast } from "sonner";
import { contract } from "@/lib/contract";

interface CapsuleCardProps {
  capsuleId: string;
}

export default function CapsuleCard({ capsuleId }: CapsuleCardProps) {
  const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);

  const { data: capsuleDetails, refetch: refetchDetails } = useReadContract({
    contract,
    method:
      "function getCapsuleDetails(uint256 _id) view returns (address creator, uint256 unlockTime, bool isPrivate, bool isOpened, address opener)",
    params: [BigInt(capsuleId)],
  });

  const { data: capsuleContent, refetch: refetchContent } = useReadContract({
    contract,
    method:
      "function viewCapsule(uint256 _id) view returns (string message, string[] attachments)",
    params: [BigInt(capsuleId)],
    queryOptions: {
      enabled: false, // Only fetch when needed
    },
  });

  const { mutate: sendAndConfirmTransaction, isPending: isOpening } =
    useSendAndConfirmTransaction();

  if (!capsuleDetails) return null;

  const [creator, unlockTime, isPrivate, isOpened, opener] = capsuleDetails;
  const unlockDate = new Date(Number(unlockTime) * 1000);
  const isUnlocked = Date.now() >= unlockDate.getTime();
  const canOpen = isUnlocked && !isOpened;

  const handleOpen = async () => {
    try {
      const transaction = prepareContractCall({
        contract,
        method: "function openCapsule(uint256 _id)",
        params: [BigInt(capsuleId)],
      });

      sendAndConfirmTransaction(transaction, {
        onSuccess: () => {
          toast.success("Capsule opened successfully!");
          refetchDetails();
          refetchContent();
        },
        onError: (error) => {
          console.error("Error opening capsule:", error);
          toast.error("Failed to open capsule");
        },
      });
    } catch (error) {
      console.error("Error preparing transaction:", error);
      toast.error("Failed to prepare transaction");
    }
  };

  const handleView = () => {
    if (isOpened || (isPrivate && creator)) {
      refetchContent();
      setIsViewDialogOpen(true);
    } else if (!isUnlocked) {
      toast.error("Capsule is still locked");
    } else {
      toast.error("You cannot view this private capsule");
    }
  };

  return (
    <>
      <Card
        className={`transition-all hover:shadow-lg ${
          isUnlocked ? "border-green-200 bg-green-50" : "border-gray-200"
        }`}
      >
        <CardHeader className="pb-3">
          <div className="flex items-center justify-between">
            <CardTitle className="text-lg">Capsule #{capsuleId}</CardTitle>
            <div className="flex gap-2">
              <Badge variant={isPrivate ? "secondary" : "outline"}>
                {isPrivate ? (
                  <Lock className="w-3 h-3 mr-1" />
                ) : (
                  <Unlock className="w-3 h-3 mr-1" />
                )}
                {isPrivate ? "Private" : "Public"}
              </Badge>
              {isOpened && (
                <Badge variant="default" className="bg-green-600">
                  Opened
                </Badge>
              )}
            </div>
          </div>
          <CardDescription className="flex items-center gap-2">
            <Calendar className="w-4 h-4" />
            {isUnlocked
              ? "Unlocked"
              : `Unlocks ${unlockDate.toLocaleDateString()}`}
          </CardDescription>
        </CardHeader>

        <CardContent className="space-y-4">
          <div className="flex items-center gap-2 text-sm text-gray-600">
            <User className="w-4 h-4" />
            <span>
              Creator: {creator.slice(0, 6)}...{creator.slice(-4)}
            </span>
          </div>

          {isOpened && opener && (
            <div className="flex items-center gap-2 text-sm text-gray-600">
              <Eye className="w-4 h-4" />
              <span>
                Opened by: {opener.slice(0, 6)}...{opener.slice(-4)}
              </span>
            </div>
          )}

          <div className="flex items-center gap-2 text-sm">
            <Clock className="w-4 h-4" />
            <span className={isUnlocked ? "text-green-600" : "text-orange-600"}>
              {isUnlocked
                ? "Ready to open"
                : `${Math.ceil(
                    (unlockDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
                  )} days remaining`}
            </span>
          </div>

          <div className="flex gap-2 pt-2">
            {canOpen && (
              <Button
                onClick={handleOpen}
                disabled={isOpening}
                className="flex-1 bg-green-600 hover:bg-green-700"
              >
                {isOpening ? "Opening..." : "Open Capsule"}
              </Button>
            )}

            <Button
              onClick={handleView}
              variant="outline"
              className="flex-1"
              disabled={!isOpened && (!isUnlocked || isPrivate)}
            >
              <Eye className="w-4 h-4 mr-2" />
              View
            </Button>
          </div>
        </CardContent>
      </Card>

      <Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
        <DialogContent className="max-w-2xl">
          <DialogHeader>
            <DialogTitle>Capsule #{capsuleId}</DialogTitle>
            <DialogDescription>
              Created on{" "}
              {new Date(
                Number(unlockTime) * 1000 -
                  Number.parseInt(capsuleId) * 24 * 60 * 60 * 1000
              ).toLocaleDateString()}
            </DialogDescription>
          </DialogHeader>

          {capsuleContent && (
            <div className="space-y-4">
              <div>
                <h4 className="font-medium mb-2">Message</h4>
                <div className="p-4 bg-gray-50 rounded-lg">
                  <p className="whitespace-pre-wrap">{capsuleContent[0]}</p>
                </div>
              </div>

              {capsuleContent[1] && capsuleContent[1].length > 0 && (
                <div>
                  <h4 className="font-medium mb-2">Attachments</h4>
                  <div className="space-y-2">
                    {capsuleContent[1].map(
                      (attachment: string, index: number) => (
                        <div
                          key={index}
                          className="p-3 bg-gray-50 rounded border"
                        >
                          <p className="text-sm break-all">{attachment}</p>
                        </div>
                      )
                    )}
                  </div>
                </div>
              )}
            </div>
          )}
        </DialogContent>
      </Dialog>
    </>
  );
}

What is happening above here is a visual rendering of a Capsule card that allows users to view or open a time-locked capsule on the blockchain, with real-time UI feedback, unlock logic, and contract reads and writes using thirdweb/react. This component is focused on making sure the capsule behaves as expected based on whether it’s private, opened, locked, or unlocked.

Let’s quickly look at what’s happening here and how this component interacts with the smart contract using thirdweb.

1. Reading Capsule Details from the Contract

First, we’re using the useReadContract hook to fetch the capsule details like the creator address, unlock time, whether it’s private or opened, and who opened it:

const { data: capsuleDetails, refetch: refetchDetails } = useReadContract({
  contract,
  method: "function getCapsuleDetails(uint256 _id) view returns (address creator, uint256 unlockTime, bool isPrivate, bool isOpened, address opener)",
  params: [BigInt(capsuleId)],
});

We also prepare another read call to view the capsule content, but it’s only triggered when needed (i.e., not on initial load):

const { data: capsuleContent, refetch: refetchContent } = useReadContract({
  contract,
  method: "function viewCapsule(uint256 _id) view returns (string message, string[] attachments)",
  params: [BigInt(capsuleId)],
  queryOptions: {
    enabled: false,
  },
});

2. Sending a Transaction to Open a Capsule

Then, we bring in the useSendAndConfirmTransaction hook from thirdweb:

const { mutate: sendAndConfirmTransaction, isPending: isOpening } = useSendAndConfirmTransaction();

This provides the sendAndConfirmTransaction function for calling a contract write function, and the isOpening boolean for tracking the pending state.

In the handleOpen function, we handle opening the capsule:

const handleOpen = async () => {
  try {
    const transaction = prepareContractCall({
      contract,
      method: "function openCapsule(uint256 _id)",
      params: [BigInt(capsuleId)],
    });

    sendAndConfirmTransaction(transaction, {
      onSuccess: () => {
        toast.success("Capsule opened successfully!");
        refetchDetails();
        refetchContent();
      },
      onError: (error) => {
        console.error("Error opening capsule:", error);
        toast.error("Failed to open capsule");
      },
    });
  } catch (error) {
    console.error("Error preparing transaction:", error);
    toast.error("Failed to prepare transaction");
  }
};

Here, we’re preparing the call to openCapsule, and passing it to sendAndConfirmTransaction. If it succeeds, we refetch both the capsule details and content to reflect the latest state in the UI.

3. Conditional Viewing Logic

The handleView function controls when a user can view the capsule content. It checks:

  • If the capsule is opened (public)

  • If the user is the creator (for private capsules)

  • If the unlock time has passed

If these conditions aren’t met, it shows an appropriate toast message:

const handleView = () => {
  if (isOpened || (isPrivate && creator)) {
    refetchContent();
    setIsViewDialogOpen(true);
  } else if (!isUnlocked) {
    toast.error("Capsule is still locked");
  } else {
    toast.error("You cannot view this private capsule");
  }
};

4. UI Display Logic

The card displays:

  • Capsule ID

  • Unlock status and time

  • Creator address

  • Opener address (if already opened)

  • Whether it is private or public

  • Option to open the capsule (if conditions are met)

  • Option to view the capsule (depending on permissions)

We use conditional logic to style the capsule (e.g. green for unlocked), and display action buttons depending on the capsule’s state:

{canOpen && (
  <Button onClick={handleOpen} disabled={isOpening} className="flex-1 bg-green-600 hover:bg-green-700">
    {isOpening ? "Opening..." : "Open Capsule"}
  </Button>
)}

5. View Dialog Modal

Once the capsule content is available (via refetchContent), a modal dialog shows the message and attachments associated with that capsule:

{capsuleContent && (
  <div className="space-y-4">
    <div>
      <h4 className="font-medium mb-2">Message</h4>
      <div className="p-4 bg-gray-50 rounded-lg">
        <p className="whitespace-pre-wrap">{capsuleContent[0]}</p>
      </div>
    </div>

    {capsuleContent[1] && capsuleContent[1].length > 0 && (
      <div>
        <h4 className="font-medium mb-2">Attachments</h4>
        <div className="space-y-2">
          {capsuleContent[1].map((attachment: string, index: number) => (
            <div key={index} className="p-3 bg-gray-50 rounded border">
              <p className="text-sm break-all">{attachment}</p>
            </div>
          ))}
        </div>
      </div>
    )}
  </div>
)}

In summary, in this component, we’re:

  • Fetching capsule data from the contract on load

  • Conditionally allowing the user to open or view a capsule based on unlock time, privacy, and ownership

  • Sending blockchain transactions to open the capsule using sendAndConfirmTransaction

  • Giving real-time feedback via toast

  • Rendering a beautiful, state-aware UI with clear call-to-actions

Then we create a new component called: components/all-capsules.tsx.

"use client"

import { useState, useEffect } from "react"
import { useReadContract } from "thirdweb/react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Globe } from "lucide-react"
import CapsuleCard from "./capsule-card"
import { contract } from "@/lib/contract"

export default function AllCapsules() {
  const [allCapsuleIds, setAllCapsuleIds] = useState<string[]>([])
  const [loading, setLoading] = useState(true)

  const { data: nextCapsuleId } = useReadContract({
    contract,
    method: "function nextCapsuleId() view returns (uint256)",
    params: [],
  })

  useEffect(() => {
    const fetchPublicCapsules = async () => {
      if (!nextCapsuleId) return

      const ids: string[] = []
      const totalCapsules = Number(nextCapsuleId)

      // Check each capsule to see if it's public (limit to 50 for performance)
      for (let i = 0; i < Math.min(totalCapsules, 50); i++) {
        try {
          ids.push(i.toString())
        } catch (error) {
          console.error(`Error fetching capsule ${i}:`, error)
        }
      }

      setAllCapsuleIds(ids.slice(0, 20))
      setLoading(false)
    }

    fetchPublicCapsules()
  }, [nextCapsuleId])

  if (loading) {
    return (
      <div className="space-y-6">
        <div className="flex items-center justify-between">
          <h2 className="text-xl font-semibold text-gray-900">All Time Capsules</h2>
          <Badge variant="secondary">Loading...</Badge>
        </div>

        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {[...Array(6)].map((_, i) => (
            <Card key={i} className="animate-pulse">
              <CardContent className="p-6">
                <div className="h-4 bg-gray-200 rounded mb-4"></div>
                <div className="h-3 bg-gray-200 rounded mb-2"></div>
                <div className="h-3 bg-gray-200 rounded w-2/3"></div>
              </CardContent>
            </Card>
          ))}
        </div>
      </div>
    )
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
          <Globe className="w-5 h-5 text-blue-600" />
          All Time Capsules
        </h2>
        <Badge variant="secondary">{allCapsuleIds.length} capsules</Badge>
      </div>

      {allCapsuleIds.length > 0 ? (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {allCapsuleIds.map((id) => (
            <CapsuleCard key={id} capsuleId={id} />
          ))}
        </div>
      ) : (
        <Card>
          <CardContent className="p-12 text-center">
            <Globe className="w-12 h-12 text-gray-400 mx-auto mb-4" />
            <h3 className="text-lg font-medium text-gray-900 mb-2">No capsules yet</h3>
            <p className="text-gray-600">Be the first to create a time capsule!</p>
          </CardContent>
        </Card>
      )}
    </div>
  )
}

This fetches up until the latest capsule or up to 50 capsules by the next capsule id, and shows the first 20 capsules, displays a loading skeleton while fetching the capsules.

Now, let’s integrate all of these into the main page in the page.tsx:

//previous imports 

import { contract } from "@/lib/contract";
import CreateCapsuleDialog from "@/components/create-capsule-dialog";
import CapsuleCard from "@/components/capsule-card";
import AllCapsules from "@/components/all-capsules";

export default function HomePage() {
  const account = useActiveAccount();
  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);

  const { data: myCapsuleIds, isLoading: loadingMyCapsules } = useReadContract({
    contract,
    method:
      "function getCapsulesByCreator(address _creator) view returns (uint256[])",
    params: [account?.address || "0x0"],
    queryOptions: {
      enabled: !!account?.address,
    },
  });

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50">
      <div className="container mx-auto px-4 py-8">
        {/* Header */}
        {/* previous code */}

        {/* Getting Started */}
        {/* previous code */}   

        {/* We've just added this main content section where all capsules and the currently connected user created capsules */}
        {/* Main Content */} 
        <Tabs defaultValue="my-capsules" className="space-y-6">
          <TabsList className="grid w-full grid-cols-2">
            <TabsTrigger value="my-capsules">My Capsules</TabsTrigger>
            <TabsTrigger value="all-capsules">All Capsules</TabsTrigger>
          </TabsList>

          <TabsContent value="my-capsules" className="space-y-6">
            <div className="flex items-center justify-between">
              <h2 className="text-xl font-semibold text-gray-900">
                My Time Capsules
              </h2>
              <Badge variant="secondary">
                {myCapsuleIds?.length || 0} capsules
              </Badge>
            </div>

            {loadingMyCapsules ? (
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                {[...Array(3)].map((_, i) => (
                  <Card key={i} className="animate-pulse">
                    <CardContent className="p-6">
                      <div className="h-4 bg-gray-200 rounded mb-4"></div>
                      <div className="h-3 bg-gray-200 rounded mb-2"></div>
                      <div className="h-3 bg-gray-200 rounded w-2/3"></div>
                    </CardContent>
                  </Card>
                ))}
              </div>
            ) : myCapsuleIds && myCapsuleIds?.length > 0 ? (
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                {myCapsuleIds.map((id: bigint) => (
                  <CapsuleCard key={id.toString()} capsuleId={id.toString()} />
                ))}
              </div>
            ) : (
              <Card>
                <CardContent className="p-12 text-center">
                  <Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
                  <h3 className="text-lg font-medium text-gray-900 mb-2">
                    No capsules yet
                  </h3>
                  <p className="text-gray-600 mb-4">
                    Create your first time capsule to get started
                  </p>
                  <Button
                    onClick={() => setIsCreateDialogOpen(true)}
                    className="bg-purple-600 hover:bg-purple-700"
                  >
                    <Plus className="w-4 h-4 mr-2" />
                    Create Capsule
                  </Button>
                </CardContent>
              </Card>
            )}
          </TabsContent>

          <TabsContent value="all-capsules">
            <AllCapsules />
          </TabsContent>
        </Tabs>

        <CreateCapsuleDialog
          isOpen={isCreateDialogOpen}
          onClose={() => setIsCreateDialogOpen(false)}
        />
      </div>
    </div>
  );
}

Here, we’re fetching the connected user created capsules and displaying them on this page also also displaying all capsules on this page.

Now, you’d see this

Let’s try to create a capsule now and see how it works, I’m gonna create some now.

And lastly, what would this time capsule dApp be without some sort of metrics tracking? Let’s show some stats. Create a new file components/capsule-stats.tsx:

"use client"

import { useActiveAccount, useReadContract } from "thirdweb/react"
import { Card, CardContent } from "@/components/ui/card"
import { Clock, Unlock, Package, Calendar } from "lucide-react"
import { contract } from "@/lib/contract"

export default function CapsuleStats() {
  const account = useActiveAccount()

  const { data: myCapsuleIds } = useReadContract({
    contract,
    method: "function getCapsulesByCreator(address _creator) view returns (uint256[])",
    params: [account?.address || "0x0"],
    queryOptions: {
      enabled: !!account?.address,
    },
  })

  const { data: totalCapsules } = useReadContract({
    contract,
    method: "function nextCapsuleId() view returns (uint256)",
    params: [],
  })

  const myCapsulesCount = myCapsuleIds?.length || 0
  const totalCapsulesCount = totalCapsules ? Number(totalCapsules) : 0

  const stats = [
    {
      icon: Package,
      label: "My Capsules",
      value: myCapsulesCount,
      color: "text-purple-600",
      bgColor: "bg-purple-100",
    },
    {
      icon: Calendar,
      label: "Total Created",
      value: totalCapsulesCount,
      color: "text-blue-600",
      bgColor: "bg-blue-100",
    },
    {
      icon: Clock,
      label: "Pending",
      value: "-", // Would calculate from capsule details
      color: "text-orange-600",
      bgColor: "bg-orange-100",
    },
    {
      icon: Unlock,
      label: "Opened",
      value: "-", // Would calculate from capsule details
      color: "text-green-600",
      bgColor: "bg-green-100",
    },
  ]

  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      {stats.map((stat, index) => (
        <Card key={index} className="hover:shadow-md transition-shadow">
          <CardContent className="p-4">
            <div className="flex items-center gap-3">
              <div className={`p-2 rounded-lg ${stat.bgColor}`}>
                <stat.icon className={`w-5 h-5 ${stat.color}`} />
              </div>
              <div>
                <p className="text-xs font-medium text-gray-600">{stat.label}</p>
                <p className="text-lg font-bold text-gray-900">{stat.value}</p>
              </div>
            </div>
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

And now let’s add this to our page.tsx:

"use client";

//previous imports
import CapsuleStats from "@/components/capsule-stats";

export default function HomePage() {
  // previous code

  return (
    <div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50">
      <div className="container mx-auto px-4 py-8">
        {/* Header */}
        {/* previous code */}

        {/* Getting Started */}
        {/* previous code */}   

        {/* Capsule Stats */}
        <div className="mb-8">
          <CapsuleStats />
        </div>

        {/* Main Content */}
        {/* previous code */}

      </div>
    </div>
  );
}

And then we have the final beautiful view of our Time Capsule dApp

All source code for this tutorial dApp can be found here https://github.com/michojekunle/time-capsule-dapp

Just flexing the dApp below here, check it out live here https://time-capsule-dapp.vercel.app/

Wrapping Up

You’ve just built a Digital Time Capsule dApp on Rootstock! A beautiful and fun project. This project combines smart contract development with frontend interaction, giving you a hands-on way to learn blockchain. With Rootstock’s low fees and Bitcoin-backed security, your time capsules are both affordable and secure.

Want to take it further? Add a countdown timer for each capsule, support multiple attachments, or explore IPFS uploads. The Rootstock Developer Portal has more resources, and the Rootstock Discord is a great place to connect with other builders. Happy coding!

0
Subscribe to my newsletter

Read articles from Michael Ojekunle directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michael Ojekunle
Michael Ojekunle

I am an Enthusiastic, curious and imaginative Web Developer, eager to contribute to team success through hard work, Attention to detail and Excellent Organizational Skills, always open to new and unconventional ideas. I take my work as a Web Developer seriously and this means I always ensure my skills are kept up to date within this rapidly changing industry.