Frontend Development

Enhancing Practical Knowledge in Frontend Development with React.js: Creating a Portfolio Builder

By the Domain India teamPublished 8 min read
Knowledge base article
Contents (9 sections)

Building a small, complete project teaches React faster than reading about it. In this tutorial you build a Portfolio Builder: a form where someone enters their name, bio, skills and projects, and a page that displays the result, with validation, editing, Bootstrap styling and a small Express API to save portfolios. It uses the current toolchain, Vite and React 19, and ends with how to put it online.

Key takeaways

Create the project with npm create vite@latest portfolio-builder -- --template react, add Bootstrap with npm install bootstrap, and build four components: Header, Footer, PortfolioForm and Portfolio. Keep the form's data in one useState object, validate on submit, and let the App component decide whether to show the form or the finished portfolio. Add an Express API behind Vite's dev proxy, then deploy the built dist/ folder as static files and run the API on a Node.js host.

1. What you need

Don't use Create React App

Older tutorials start with create-react-app. The React team deprecated it in 2025, and it no longer receives updates. Use Vite for a plain React app like this one, or a framework such as Next.js for a full website.

2. Create the project

bash
npm create vite@latest portfolio-builder -- --template react
cd portfolio-builder
npm install
npm install bootstrap
npm run dev

Vite prints a local address, usually http://localhost:5173. Open it and you'll see the starter page. Now load Bootstrap's CSS once, in src/main.jsx:

jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Delete src/App.css and the demo code in src/App.jsx, and create a src/components folder.

src/components/Header.jsx:

jsx
export default function Header() {
  return (
    <nav className="navbar navbar-dark bg-dark">
      <div className="container">
        <span className="navbar-brand">Portfolio Builder</span>
      </div>
    </nav>
  );
}

src/components/Footer.jsx:

jsx
export default function Footer() {
  return (
    <footer className="bg-dark text-white text-center py-3 mt-5">
      © {new Date().getFullYear()} Portfolio Builder
    </footer>
  );
}

Components are plain functions that return JSX. Since React 17 you don't need import React at the top of each file.

4. The form, with validation

Keep all fields in one state object, so adding a field means one change, not four.

src/components/PortfolioForm.jsx:

jsx
import { useState } from 'react';

const EMPTY = { name: '', bio: '', skills: '', projects: '' };
const FIELDS = [
  { key: 'name', label: 'Name', type: 'input' },
  { key: 'bio', label: 'Short bio', type: 'textarea' },
  { key: 'skills', label: 'Skills (comma separated)', type: 'input' },
  { key: 'projects', label: 'Projects', type: 'textarea' },
];

export default function PortfolioForm({ initial = EMPTY, onSubmit }) {
  const [values, setValues] = useState(initial);
  const [errors, setErrors] = useState({});

  function handleChange(e) {
    setValues({ ...values, [e.target.name]: e.target.value });
  }

  function handleSubmit(e) {
    e.preventDefault();
    const newErrors = {};
    for (const { key, label } of FIELDS) {
      if (!values[key].trim()) newErrors[key] = `${label} is required.`;
    }
    setErrors(newErrors);
    if (Object.keys(newErrors).length === 0) onSubmit(values);
  }

  return (
    <form className="container mt-4" onSubmit={handleSubmit} noValidate>
      {FIELDS.map(({ key, label, type }) => {
        const Tag = type === 'textarea' ? 'textarea' : 'input';
        return (
          <div className="mb-3" key={key}>
            <label htmlFor={key} className="form-label">{label}</label>
            <Tag
              id={key}
              name={key}
              value={values[key]}
              onChange={handleChange}
              className={`form-control ${errors[key] ? 'is-invalid' : ''}`}
            />
            {errors[key] && <div className="invalid-feedback">{errors[key]}</div>}
          </div>
        );
      })}
      <button type="submit" className="btn btn-primary">Build my portfolio</button>
    </form>
  );
}

Three things to notice: each input is controlled (its value comes from state), the name attribute lets one handleChange update any field, and the form receives onSubmit as a prop, so it doesn't need to know what happens next.

5. Displaying the portfolio

src/components/Portfolio.jsx:

jsx
export default function Portfolio({ data, onEdit }) {
  const skills = data.skills.split(',').map((s) => s.trim()).filter(Boolean);

  return (
    <div className="container mt-4">
      <h1>{data.name}</h1>
      <p className="lead">{data.bio}</p>
      <h2 className="h4 mt-4">Skills</h2>
      <ul className="list-inline">
        {skills.map((skill) => (
          <li key={skill} className="list-inline-item badge text-bg-secondary">{skill}</li>
        ))}
      </ul>
      <h2 className="h4 mt-4">Projects</h2>
      <p style={{ whiteSpace: 'pre-line' }}>{data.projects}</p>
      <button className="btn btn-outline-secondary" onClick={onEdit}>Edit</button>
    </div>
  );
}

React escapes text you render with { }, so a visitor typing HTML into the bio can't inject a script. Never switch that off with dangerouslySetInnerHTML for user input.

6. Putting it together in App

src/App.jsx:

jsx
import { useState } from 'react';
import Header from './components/Header.jsx';
import Footer from './components/Footer.jsx';
import PortfolioForm from './components/PortfolioForm.jsx';
import Portfolio from './components/Portfolio.jsx';

export default function App() {
  const [portfolio, setPortfolio] = useState(null);
  const [editing, setEditing] = useState(true);

  function handleSubmit(data) {
    setPortfolio(data);
    setEditing(false);
  }

  return (
    <>
      <Header />
      {editing ? (
        <PortfolioForm initial={portfolio ?? undefined} onSubmit={handleSubmit} />
      ) : (
        <Portfolio data={portfolio} onEdit={() => setEditing(true)} />
      )}
      <Footer />
    </>
  );
}

State lives in the component that needs it for decisions: App owns the portfolio and the "editing" flag, and passes data down and callbacks up. Clicking Edit reopens the form with the saved values.

7. Save portfolios with a small Express API

In a second terminal, from the project folder:

bash
mkdir server && cd server
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node index.js"
npm install express

server/index.js:

js
import express from 'express';

const app = express();
app.use(express.json({ limit: '20kb' }));

const portfolios = []; // in memory: lost when the server restarts

app.get('/api/portfolios', (req, res) => res.json(portfolios));

app.post('/api/portfolios', (req, res) => {
  const { name, bio, skills, projects } = req.body ?? {};
  const all = [name, bio, skills, projects];
  if (!all.every((v) => typeof v === 'string' && v.trim())) {
    return res.status(400).json({ error: 'All fields are required.' });
  }
  const item = { id: crypto.randomUUID(), name, bio, skills, projects };
  portfolios.push(item);
  res.status(201).json(item);
});

const port = process.env.PORT || 3001;
app.listen(port, () => console.log(`API listening on ${port}`));

Run it with node --watch index.js; Node's built-in watch mode restarts it when you save, so you don't need nodemon. The server validates input again, because anyone can call an API directly.

Instead of opening CORS, let Vite forward /api requests to Express during development. In vite.config.js:

js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': 'http://localhost:3001' } },
});

Then save from App's handleSubmit:

jsx
async function handleSubmit(data) {
  const res = await fetch('/api/portfolios', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!res.ok) return alert('Could not save. Please try again.');
  setPortfolio(await res.json());
  setEditing(false);
}

For real use, replace the in-memory array with a database such as PostgreSQL or MySQL.

8. Ideas to take it further

  • Add React Router with a page per saved portfolio.
  • Let users add projects one by one, each with a title, link and description, as an array in state.
  • Add a photo upload, with size and type checks on the server.
  • Write tests with Vitest and React Testing Library.

9. Running this on Domain India

Run npm run build on your computer. Vite writes plain HTML, CSS and JavaScript to dist/, and those files run on any web host:

  • The front end on shared hosting. Upload the contents of dist/ to public_html with the File Manager or FTP. If you add React Router, add this .htaccess so deep links load the app; mod_rewrite is enabled on our cPanel, DirectAdmin and Webuzo servers:
apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
  • The API on the App Platform. Node.js apps are detected automatically from package.json and started with its start script, and your server already listens on the PORT variable the platform sets. Deploy from GitHub with Deploy Now or with a deploy token, and use the included PostgreSQL database through DATABASE_URL. See getting started with the App Platform. You can also serve dist/ from Express and run the whole project as one app.
  • The API on cPanel or DirectAdmin hosting. Small Node.js apps can run through the Node.js application tool in the panel; see deploying a Node.js app on shared hosting.
App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
See plan details

The card shows the live price, excluding 18% GST; on 19 September 2026, the Domain India list price for App Starter was ₹100 a month.

Should I use Create React App or Vite in 2026?

Use Vite for a plain React app. Create React App was deprecated by the React team in 2025 and no longer receives updates. For a full website with routing and server rendering, a framework such as Next.js is the other common choice.

Why keep form data in one state object?

One object with a key per field lets a single change handler update every input through its name attribute, and adding a field means adding one entry instead of a new state variable and handler.

Is user input safe to display in React?

Text rendered with curly braces in JSX is escaped automatically, so typed HTML is shown as text rather than run. Avoid dangerouslySetInnerHTML for user input, and validate data again on the server.

Can I host a React app on shared hosting?

Yes. Run npm run build and upload the contents of the dist folder to public_html. It is static HTML, CSS and JavaScript. If you use client-side routing, add an .htaccess rule that sends unknown paths to index.html.

Where should I run the Express API?

On a Node.js host. On Domain India that can be the App Platform, which detects Node.js apps automatically and includes a PostgreSQL database, or the Node.js application tool on cPanel and DirectAdmin hosting for small apps.

Ready to put your portfolio online? Deploy the API on the App Platform, or host the built front end on cPanel hosting. For more projects, see the MERN stack learning index.

Deploy your Node.js API

Node.js detected automatically, a PostgreSQL database included, and free SSL on your own domain.

See App Platform plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
React Project: Build a Portfolio Builder (Vite, 2026)