Build a Website Maker that creates and deploys a decentralized webpage using Web3.storage
A full stack Web2 app that creates a one page Web3 decentralized website.
We will build a frontend with HTML, CSS, and Vanilla JS where users can upload a picture along with the alt text and write a website title.
We will build a backend with Express.JS that receives the uploaded files and store them in Web3.storage, decentralized storage backed by Filecoin.
The final result will be a website maker, (demo link), that will create a decentralized website - example.
My Table of content
- Intended Audience
- What are we Building?
- Prerequisite
- Folder Structure
- Create Frontend Website
- Create Express.JS Backend Server
Intended Audience
- Experience or familiarity with creating a full-stack app
- Newbies are welcome. Follow along and google (I use DuckDuckGo) anytime you need more clarity. Intermediate/Advanced programmers do this all the time.
What are we Building?
- A website (demo link) can create and deploy a one-page decentralized website
- We will be using HTML, CSS, and Vanilla JavaScript to build the frontend website.
- We will be using the expressJS framework to create our backend server.
- The frontend code and backend code is publicly available at GitHub.
- They are split up into two repos. For some reason, I don't like mono-repos, but I'm willing to change my mind.
- We won't be covering the code step by step with an explanation.
- Instead, I will explain what the code is doing at a high to medium level
- Instead, I will explain what the code is doing at a high to medium level
Prerequisite
- Any code editor (I use VSCode)
- Node.js - here is a link to install
Folder Structure
Frontend
project folder
│ index.html
│ script.js
│ style.css
Backend
project folder
│ .env
│ .gitignore
│ server.js
│
└───controller
│ │ createFolders.js
│ │ createHtmlFile.js
│ │ storeFilesToWeb3Storage.js
│ │ uploadFilesFromClient.js
└───routes
│ │ receiveFilesFromClient.js
Create Frontend Website
Open up your code editor (I use VSCode). Create a folder. I called mine web3-static-website-maker-frontend
.
Create an HTML file called index.html
Below is the HTML code. I will explain some of the parts that I think are important after the code.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="make web3 decentralized websites using Web3.storage"
/>
<meta name="keywords" content="web3 decentralized web3.storage" />
<meta name="author" content="codingraj" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Merriweather+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/style.css" />
<script src="/script.js" defer></script>
<title>Web3 Static Website Maker</title>
</head>
<body>
<header>
<div class="logo-title">Web3 Decentralized Website Maker</div>
</header>
<main>
<h1>Create a one page website hosted on Web3.storage</h1>
<br /><br />
<p>
Get your
<a href="https://docs.web3.storage/#get-an-api-token" target="_blank">
Free Web3.storage API Token here</a
>
</p>
<br />
<div class="card">
<label for="website-title">Web3.storage API Token</label>
<span class="inputRequired"></span>
<span class="incorrectAPI" id="incorrectAPI-id"></span>
<br />
<input
type="text"
name="api-token"
id="api-token-id"
placeholder="API token not saved on the server"
required
autofocus
/>
<br />
<br />
<label for="header-title">Header Title</label>
<span class="inputRequired"></span>
<br />
<input
type="text"
name="header-title"
id="header-title-id"
placeholder="ex - I love pineapple on pizza"
required
/>
<br />
<br />
<label for="">Upload Image</label>
<span class="inputRequired"></span>
<br />
<input
type="file"
accept="image/*"
name="image-file"
id="image-file-id"
required
/>
<br />
<br />
<label for="image-alt-text">Image Alt Text</label>
<br />
<textarea
name="image-alt-text"
id="image-alt-text-id"
placeholder="ex - a slice of pizza with pineapple toppings on a paper plate"
></textarea>
<br />
<div class="buttonRequired" id="buttonRequired-id"></div>
<br />
<button id="generate-button-id" data-cy="generate-button">
Create Website on Web3.storage
</button>
<br /><br /><br />
<a class="web3-website-link" id="web3-website-link-id" target="_blank”">
your new website
</a>
</div>
</main>
<footer>made with <span class="heart">♥</span> @codingraj</footer>
</body>
</html>
Important Parts:
- 3 sections
- Header
- Main
- Card div for the input fields and button
- I like the card layout
- Card div for the input fields and button
- Footer
- Input fields
- Web3.Storage API Token
- This is needed to use their API to store files
- Required field
- Header Title
- Displayed above the image
- Required field
- Upload Image
- Restricted to selecting only image files
- Required field
- Image Alt Text
- Ability to add alt text for the image file
- Optional field
- Upload Image
- Restricted to selecting only image files
- Required field
- Span tags
- To display error messages for
- Incorrect API Token
- Incomplete Fields
- Button
- Submit user input
- New Website Link
- After clicking the button, the server will send a URL for the new decentralized website
- To display error messages for
- Web3.Storage API Token
Add some CSS to format the layout with Grid and add some color.
Create a CSS file called: style.css
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Remove default margin */
body,
h1,
h2,
h3,
h4,
p,
.logo-title {
margin: 0;
}
/* Set core root defaults */
html {
scroll-behavior: smooth;
}
/* Set core body defaults */
body {
text-rendering: optimizeSpeed;
line-height: 1.5;
}
:root {
--font-family: "Merriweather Sans", sans-serif;
--color-background-header-footer: #d1bdf7;
--color-button-background: #1a45df;
--color-primary-font: #000036;
--color-secondary-font: #ffffff;
--color-complimentary-font: #1a45df;
--color-placeholder-font: #4e5b62;
--font-size-sm: clamp(0.5rem, 0.5rem + 1.5vw, 2rem);
--font-size-100: 0.775rem;
--font-size-200: 0.875rem;
--font-size-300: 1rem;
--font-size-400: 1.125rem;
--font-size-500: 1.25rem;
--font-size-600: 1.5rem;
--font-size-700: 1.875rem;
--font-size-xl: clamp(2rem, 1rem + 3vw, 5.75rem);
}
h1 {
font-size: var(--font-size-700);
line-height: 1.1;
}
.logo-title {
font-size: var(--font-size-700);
}
/* Set Grid Layout, min-height, some color, and padding on the top and bottom */
body {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 5fr auto;
min-height: 100vh;
gap: 2vw;
font-family: var(--font-family);
text-align: center;
}
header {
font-size: var(--font-size-700);
grid-row: 1 / 2;
padding-block: 1vw;
background: var(--color-background-header-footer);
}
main {
grid-row: 2 / 3;
}
footer {
padding-block: 1vw;
grid-row: 3 / 4;
background: var(--color-background-header-footer);
}
/* # card */
.card {
background: var(--color-background-header-footer);
outline: none;
padding: 1.5rem;
border-radius: 8px;
width: 90vw;
margin: auto;
}
@media only screen and (min-width: 50em) {
.card {
width: 60vw;
}
}
input {
padding: 0.75em 0.5em;
border: 2px solid;
border-radius: 4px;
font-size: var(--font-size-300);
width: 90%;
}
label {
font-size: 1.125rem;
font-weight: 500;
line-height: 2;
}
/* # input and textarea fields */
input:required {
border-color: #1a45df;
border-width: 2px;
}
textarea {
width: 90%;
height: 15%;
padding: 0.75em 0.5em;
border: 2px solid;
border-radius: 4px;
}
@media only screen and (min-width: 50em) {
input,
textarea {
width: 70%;
}
}
::placeholder {
color: var(--color-placeholder-font);
font-size: var(--font-size-100);
}
/* # buttons */
button {
border: none;
background-color: var(--color-button-background);
border-radius: 10px;
padding: 0.4rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-secondary-font);
cursor: pointer;
text-align: center;
}
.inputRequired:after {
content: "required";
font-size: var(--font-size-100);
color: var(--color-complimentary-font);
margin-left: 0.5vw;
}
/* # error message display and style */
.buttonRequired {
display: none;
}
.buttonRequired:after {
font-size: var(--font-size-100);
color: var(--color-complimentary-font);
margin-left: 0.5vw;
}
.incorrectAPI {
font-size: var(--font-size-100);
color: red;
margin-left: 0.5vw;
}
/* # decentralized website link returned from server */
.web3-website-link {
border: 5px solid orange;
padding: 1vw;
width: fit-content;
margin: auto;
animation: blink 1.5s infinite;
display: none;
}
/* # loading animation after user clicks button */
.loading {
position: relative;
overflow: hidden;
}
.loading:before {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
content: "";
z-index: 1;
background: pink;
animation: slide 1.5s ease infinite;
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(340%);
}
}
@keyframes blink {
0% {
border-color: lightgreen;
}
50% {
border-color: darkgreen;
}
100% {
border-color: lightgreen;
}
}
/* # heart color for footer */
.heart {
color: red;
}
Important Parts:
- CSS Grid for layout
- 3-row layout
- Used semantic tags instead of div tags for layout
- Card div to group input fields
- Media Query to make the Card width smaller for larger screens
- Input + Label styling
- Placeholder text styling
- Required text label styling
- Media Query to make the input width smaller for larger screens
- Button styling
- Loading animation to display after clicking the button
- Span tag styling
- The span tags is where the error message will be displayed
- New Website Link styling
Create a file called script.js
in the root folder.
// declare environment
// to determine API url
let env = "dev";
let url;
if (env === "dev") {
url = "http://localhost:8080/upload-files";
}
if (env === "prod") {
url = "https://web3-website-maker.herokuapp.com/upload-files";
}
// select button Id
const createWebsiteButton = document.getElementById("generate-button-id");
// add click event listener that
// will get Input Ids, Input Values
createWebsiteButton.addEventListener("click", async () => {
const apiTokenInput = document.getElementById("api-token-id");
const fileInput = document.getElementById("image-file-id");
const headerTitleInput = document.getElementById("header-title-id");
const altImageInput = document.getElementById("image-alt-text-id");
// check if Input fields is complete
// if complete then call Function uploadFile
// pass file selected, api token value, alt image value, and header title value
if (
apiTokenInput.value != "" &&
headerTitleInput.value != "" &&
fileInput.value != ""
) {
uploadFile(fileInput, apiTokenInput, altImageInput, headerTitleInput);
} else {
// if the fields are incomplete
// display error message
document.getElementById("buttonRequired-id").style.display = "block";
document.getElementById("buttonRequired-id").style.color = "orange";
document.getElementById("buttonRequired-id").innerText =
"fill in required fields";
}
});
async function uploadFile(
fileInput,
apiTokenInput,
altImageInput,
headerTitleInput
) {
// create random 4 digit number to create folder on server with a unique name
var folderName = String(Math.floor(1000 + Math.random() * 9000));
// grab website link element ID
// we will use this to display the link after a successful response from the server
const websiteLink = document.getElementById("web3-website-link-id");
// add file and user input to FormData object
const userFormData = new FormData();
userFormData.append("folderName", folderName);
userFormData.append("image", fileInput.files[0]);
userFormData.append("headerTitle", headerTitleInput.value);
userFormData.append("token", apiTokenInput.value);
userFormData.append("altImage", altImageInput.value);
console.log([...userFormData]);
createWebsiteButton.classList.add("loading");
createWebsiteButton.innerText = "wait";
websiteLink.style.display = "none";
document.getElementById("buttonRequired-id").innerText = "";
document.getElementById("incorrectAPI-id").innerText = "";
document.getElementById("api-token-id").style.borderColor = "#1a45df";
// send `POST` request
await fetch(url, {
mode: "cors",
method: "POST",
headers: {
Accept: "application/json",
},
body: userFormData,
})
.then((res) => {
// if status is OK from server, then set the button to its original text
if (res.status === 200) {
createWebsiteButton.classList.remove("loading");
createWebsiteButton.innerText = "Create Website on Web3.storage";
return res.json();
}
})
.then((data) => {
// display the website link with the URL from the backend server
websiteLink.style.display = "block";
websiteLink.href = data.url;
headerTitleInput.value = "";
fileInput.value = "";
})
.catch((err) => {
// catch error and display a message
// apply border styling
// set the button to its original text
console.log("error: " + err);
document.getElementById("api-token-id").style.borderColor = "red";
document.getElementById("incorrectAPI-id").innerText =
"Something went wrong. Your API token might be incorrect";
createWebsiteButton.classList.remove("loading");
createWebsiteButton.innerText = "Create Website on Web3.storage";
});
}
Important Parts:
- Fetch call to send and receive from backend server
- send different data types as Form Object
- Added wait text and loading animation after clicking the button
- Added error handling if
- Origin is incorrect
- The backend server will only accept the API request if it comes from the frontend website origin.
- API token is incorrect
- The form is incomplete
- Origin is incorrect
- Display new website link after a successful response from backend server
Create Express.JS Backend Server
Open up a new project folder to create your backend server. I called mine web3-static-website-maker-backend
.
In the terminal, type to initialize our repository.
npm init
Now install NPM packages we will use to build our backend:
npm i express express-fileupload express-validator web3.storage cors dotenv
express
- Node.js framework for creating our backend server and API endpoints
express-fileupload
- To handle the files being uploaded from the frontend
express-validator
- To sanitize user input coming from the frontend
web3.storage
- Library that will upload and store files in decentralized storage
cors
- To restrict the use of our backend APIs to request coming from our frontend
dotenv
- Use a .env file to store the origin URL in our backend. It is optional since the origin URL does not need to be private. I used it, so I don't have to keep changing the origin for dev and prod environments.
Now we will install nodemon
as a dev dependency - To restart our dev server when there are any changes.
npm i nodemon -D
After the npm install, this will create a package.json
file. Open the file, remove the original scripts section (we will be adding our own), and add the following attributes.
"type": "module",
"scripts": {
"devTest": "nodemon server.js",
"start": "node server.js"
},
We will be using ES6 module imports for the backend server, so the type
is required.
The scripts' attributes are the commands to start our dev and prod backend server.
Create a file called server.js
and put it in the root directory.
// server setup
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import fileUpload from "express-fileupload";
// API routes
import receiveFilesFromClient from "./routes/receiveFilesFromClient.js";
const app = express();
// enable express-filesupload
app.use(fileUpload());
// bring the API routs
app.use(receiveFilesFromClient);
// start server
const port = process.env.PORT;
app.listen(port || 8080, () => {
console.log("all systems are a go on port " + port);
});
Important Parts:
- Created backend server with Express.JS
Create a folder called routes
. Create a file in that folder called receiveFilesFromClient.js
.
Type the following code:
import dotenv from "dotenv";
dotenv.config();
import { Router } from "express";
import cors from "cors";
// import all the logic for the API routes
import { createFolders } from "../controller/createFolders.js";
import { createHtmlFile } from "../controller/createHtmlFile.js";
import { storeFilesToWeb3Storage } from "../controller/storeFilesToWeb3Storage.js";
import { uploadFilesFromClient } from "../controller/uploadFilesFromClient.js";
// used to validate the user input from the frontend
import { body } from "express-validator";
// create Express router for API routes
const router = Router();
// using CORS to restrict the origin requests to come from the Frontend only
const whitelist = [process.env.ORIGIN];
const corsOptions = {
origin: function (origin, callback) {
if (whitelist.indexOf(origin) !== -1) {
console.log("origin: " + origin);
callback(null, true);
} else {
// throw error and stop API call if the origin is forbidden
// I'm using a Heroku which restarts the server if it stops
// so no need to restart server in code for production
console.log("origin: " + origin);
throw Error("Not allowed by CORS");
}
},
};
router.post(
// the route of the API
"/upload-files",
// check request origin
cors(corsOptions),
// check and sanitize any user text using express-validator
body("headerTitle").not().isEmpty().trim().escape(),
body("altImage").not().isEmpty().trim().escape(),
// call these functions in order
createFolders,
createHtmlFile,
uploadFilesFromClient,
storeFilesToWeb3Storage
);
export default router;
Important Parts:
- Checking CORS origin first
- throw an error if the origin is forbidden
- If you are using a Platform as a Service (PaaS), like Heroku, then you don't have to worry about restarting the server if an error occurs and crashes your backend server. A PaaS will restart the server for you automatically.
- Using express-validator to check and sanitize code instead of writing custom logic
- Separated the backend logic to the
Controller
folder to make the code cleaner are easier to read.
Create a folder called controller
. Create a file in that folder called createFolders.js
.
Type the following code:
import fs from "fs";
export async function createFolders(req, res, next) {
//create directory
//create temp dir
// use the folder name received from the Frontend
const tempDir = "./upload" + req.body.folderName;
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir);
}
// create a sub directory to store the file received from the Frontend
const childTempDir = tempDir + "/files/";
if (!fs.existsSync(childTempDir)) {
fs.mkdirSync(childTempDir);
}
// call next() to go to the next function on the route list
next();
}
Important Parts:
- The
fs
module is included in Express. There is no need to npm install it separately. - Create a parent folder using the unique name coming from the Frontend
- Create a subdirectory to store the file received from the Frontend
- Call next() to go to the next function on the route list
Create a file in the controller
folder called createHtmlFile.js
.
Type the following code:
import fs from "fs";
export async function createHtmlFile(req, res, next) {
// Assign variables to the received data
let headerTitle = req.body.headerTitle;
// express-fileupload will put any file received from the frontend as req.files
let imageFile = req.files.image;
let srcImage = "/files/" + imageFile.name;
let altImage = req.body.altImage;
// define folder path to store the files and user input
let path = "./upload" + req.body.folderName + "/files/";
// create HTML file from the user input
// This is the website the app is making
// included CSS styling in the style tag
let htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Merriweather+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap"
rel="stylesheet"
/>
<title>Web3 decentralized website</title>
</head>
<body>
<header>
<div class="logo-title">Web3 Decentralized Website</div>
</header>
<main>
<h1>${headerTitle}</h1>
<br />
<img src=${srcImage} alt="${altImage}" />
</main>
<footer>made with <span class="heart">♥</span></footer>
</body>
<style>
/* Box sizing rules */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Make images easier to work with */
img,
picture {
max-width: 80%;
}
:root {
--font-family-header: "Merriweather Sans", sans-serif;
--color-background-header-footer: #d1bdf7;
--color-background-body: #ffffff;
--color-complimentary-two-background: #fff4f9;
--color-primary-font: #000036;
--color-secondary-font: #ffffff;
--font-size-sm: clamp(0.5rem, 0.5rem + 1.5vw, 2rem);
--font-size-100: 0.775rem;
--font-size-200: 0.875rem;
--font-size-300: 1rem;
--font-size-400: 1.125rem;
--font-size-500: 1.25rem;
--font-size-600: 1.5rem;
--font-size-700: 1.875rem;
--font-size-xl: clamp(2rem, 1rem + 3vw, 5.75rem);
}
h1 {
font-size: var(--font-size-xl);
line-height: 1.1;
}
.logo-title {
font-size: var(--font-size-700);
}
/* Set Grid Layout, min-height, some color, and padding on the top and bottom */
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
font-family: var(--font-family-header);
}
main {
margin: auto;
text-align: center;
padding-block: 2vw;
}
header {
font-size: var(--font-size-700);
}
header,
footer {
text-align: center;
padding-block: 2vw;
background: var(--color-background-header-footer);
}
.heart {
color: red;
}
</style>
</html>`;
//create html page and save html page to file folder using the FS module
fs.writeFile(path + "index.html", htmlContent, function (err) {
if (err) throw err;
});
// call next() to go to the next function on the route list
next();
}
Important Parts:
- Assign variables to the received data
Express-fileupload
will put any file received from the frontend as req.files- Define folder path to store the files and user input
- Create an HTML file from the user input
- Save HTML file to the file folder
- Call next() to go to the next function on the route list
Create a file in the controller
folder called uploadFilesFromClient.js
.
Type the following code:
export async function uploadFilesFromClient(req, res, next) {
try {
//Use the name of the input field to retrieve the uploaded file
let imageFile = req.files.image;
//Use the mv() method to place the file in upload directory (i.e. "uploads")
imageFile.mv(
"./upload" + req.body.folderName + "/files/" + imageFile.name
);
// call next() to go to the next function on the route list
next();
} catch (err) {
res.status(500).send(err);
}
}
Important Parts:
- Move the files using
express-fileupload
to the files directory - Call next() to go to the next function on the route list
Create a file in the controller
folder called storeFilesToWeb3Storage.js
.
Type the following code:
// import the web3.storage library
import { Web3Storage, getFilesFromPath } from "web3.storage";
import fs from "fs";
export async function storeFilesToWeb3Storage(req, res) {
// define folder path where the files are located
let parentPath = "./upload" + req.body.folderName;
let path = "./upload" + req.body.folderName + "/files/";
const token = req.body.token;
// Use API token to start the storage process
const storage = new Web3Storage({ token });
// get the files that the user uploaded
const files = await getFiles(path);
console.log(`Uploading ${files.length} files`);
try {
// store files
// Store the files in web3.storage, if successful it will return a cid - address where the files are stored
const cid = await storage.put(files);
console.log("Content added with CID:", cid);
// remove the temp folders so we don't store anything the user sent on the backend server
await removeDir(parentPath);
//send new website URL in response
res.json({
message: "File is uploaded",
url: "https://" + cid + ".ipfs.dweb.link/files/index.html",
});
} catch (error) {
// if the token is incorrect, catch the error and send status to client
res.status(403).json({ error: "incorrect token: " + error });
console.error("i found error: " + error);
await removeDir(parentPath);
return false;
}
}
// function that gets the files stored in the file folder
async function getFiles(path) {
const files = await getFilesFromPath(path);
console.log(`read ${files.length} file(s) from ${path}`);
return files;
}
// function to remove file directory
async function removeDir(path) {
try {
// fs.rmdirSync(removePath);
fs.rmSync(path, { recursive: true });
} catch (error) {
console.error(error);
}
}
Important Parts:
- Use web3.storage library to store files in decentralized storage
- If the API token is incorrect, then it will throw an error
- The frontend site will display an error
- The backend site will crash but will get restarted in prod if using a PaaS, like Heroku
- remove the temp directory
- this is important because we don't want to store any files from the user on our backend server
- Send the URL of the new website that was created and deployed onto decentralized storage.
You have now created a full-stack app that makes and deploys a one-page decentralized website.