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


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.
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.tsx
We’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 "Create Capsule" 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!
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.