Build a Website Maker that creates and deploys a decentralized webpage using Web3.storage

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.

·

16 min read

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

  • 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

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">&hearts;</span> @codingraj</footer>
  </body>
</html>

Important Parts:

  • 3 sections
    • Header
    • Main
      • Card div for the input fields and button
        • I like the card layout
    • 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

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
  • 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">&hearts;</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.