React Web and Mobile App Portfolio
Build a 5-page portfolio website and a React Native greeting app.
Introduction
30 Second Summary
Whether you're job hunting or building a personal brand, a website that showcases your work is your digital handshake. A mobile app you can install on your phone proves you can ship across platforms too.
In this project, you will build a five-page React portfolio website deployed live on Render.com. You'll also create a React Native greeting app and export it as an installable Android APK.
What You'll Build
A single GitHub repository containing a five-page React portfolio website with client-side routing, forms, and an interactive game, alongside a React Native mobile app styled with NativeWind (Tailwind CSS for React Native).
By the end of this project, you'll have:
- A live portfolio website on Render.com with five pages including navigation, a contact form, data tables, and an interactive tic-tac-toe game.
- An installable Android APK of a React Native greeting app styled with NativeWind that responds to user input with personalized messages.
- A monorepo on GitHub containing both projects, demonstrating cross-platform delivery from a single codebase.
- Secret Mission: Add real-time form validation to the Contact page that highlights invalid fields as the user types.
Are there any prerequisites?
You should have basic familiarity with JavaScript and React concepts like components, props, and state. You'll also need Node.js installed on your machine and the Expo Go app on your phone.
Set Up the Monorepo and Web Project
A monorepo keeps your web and mobile projects in one place. This makes it easier to manage shared version control, commits, and deployments from a single repository.
In this step, you'll create the monorepo folder structure and scaffold a React web app using Vite. You'll also install React Router so your portfolio can support multiple pages with client-side navigation.
In this step, get ready to:
- Create the monorepo folder with Git initialized.
- Scaffold the React web app with Vite.
- Set up React Router for multi-page navigation.
Create the project folder and initialize Git
Every project needs a home. You'll create a dedicated folder on your Desktop so it's easy to find later.
- Open the integrated terminal in VS Code by pressing Ctrl+` (Windows) or Cmd+` (macOS).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder, move into it, and initialize Git by running these commands:
mkdir my-portfolio-project
cd my-portfolio-project
git init
What do these commands do?
- mkdir creates a new folder called my-portfolio-project.
- cd moves your terminal into that new folder.
- git init turns the folder into a Git repository so you can track changes.
- Confirm the folder was created by running:
ls
You should see the folder listed on your Desktop. The terminal prompt should now show my-portfolio-project as your current directory.
Seeing 'command not found' for git?
Git may not be installed on your machine. Download it from git-scm.com and restart your terminal after installation.
help me install Git on my machine
- Create a README.md file that describes your monorepo by running this command:
echo "# My Portfolio Project
A monorepo containing:
- web/ - React portfolio website (Vite)
- mobile/ - React Native greeting app (Expo)" > README.md
What is a README?
A README.md file sits at the root of your repository and tells visitors what the project is about. When you push this to GitHub later, this file displays automatically on your repository page.
- Verify the file was created by running:
ls
You should see README.md listed in the output.
Scaffold the React web app inside the repo
Vite is a fast build tool that scaffolds a ready-to-run React project in seconds. You'll create the web app inside a web/ subfolder to keep it separate from the mobile app you'll add later.
- Scaffold the React project by running this command:
npm create vite@latest web -- --template react
What does this command do?
npm create vite@latest downloads and runs Vite's project scaffolder. The web argument names the folder, and -- --template react tells Vite to use the React template without asking interactive questions.
- Move into the new web folder and install the base dependencies by running:
cd web
npm install
- Install React Router for client-side page navigation by running:
npm install react-router-dom
Why React Router?
A traditional website loads a new HTML file for every page. React Router lets your single-page app swap content instantly without a full page reload. This is called client-side routing.
- Confirm the installation succeeded by running:
ls node_modules
You should see a long list of package folders. If node_modules exists and is populated, your dependencies installed correctly.
Seeing npm errors during install?
Make sure you are inside the web folder before running npm install. If you see permission errors, try closing and reopening your terminal.
help me fix npm install errors
Start the dev server and set up routing
Before writing your own code, confirm that the scaffolded app runs. Then you'll replace the default content with your routing setup.
- Start the Vite development server by running:
npm run dev
- Open the URL shown in the terminal (usually http://localhost:5173) in your browser.
You should see the default Vite + React welcome page with a spinning logo and a counter button.
Don't see the welcome page?
Check that the terminal shows a local URL. If port 5173 is busy, Vite will use a different port. Copy the exact URL from the terminal output.
help me get the Vite dev server running
- Stop the dev server by pressing Ctrl+C in the terminal.
Now you'll replace the default app with your own routing configuration. The app will import five page components that you'll build in the next steps.
- Open src/App.jsx in the VS Code file explorer (inside the web folder). Select all the existing content and replace it with this code:
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Navbar from "./components/Navbar";
import Home from "./pages/Home";
import About from "./pages/About";
import Contact from "./pages/Contact";
import Data from "./pages/Data";
import Game from "./pages/Game";
import "./App.css";
function App() {
return (
<BrowserRouter>
<Navbar />
<main className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/data" element={<Data />} />
<Route path="/game" element={<Game />} />
</Routes>
</main>
</BrowserRouter>
);
}
export default App;
What does this code do?
- BrowserRouter wraps the entire app to enable client-side routing.
- Routes and Route define which component renders at each URL path.
- Navbar sits outside Routes so it appears on every page.
- The page imports (Home, About, Contact, Data, Game) point to files you'll create in the next steps.
- Save App.jsx.
- Now open src/main.jsx in the same folder. Replace all the existing content with this code:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
Why update main.jsx?
The default Vite scaffold imports a CSS file you won't be using. This cleaned-up version keeps only what's needed: it renders your App component into the page.
- Save main.jsx.
- Create a new folder called components inside src. Create a new file called Navbar.jsx inside it, and paste this code:
import { Link } from "react-router-dom";
function Navbar() {
return (
<nav className="navbar">
<div className="navbar-brand">My Portfolio</div>
<ul className="navbar-links">
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/contact">Contact</Link></li>
<li><Link to="/data">Data</Link></li>
<li><Link to="/game">Game</Link></li>
</ul>
</nav>
);
}
export default Navbar;
What does the Navbar do?
The Link component from React Router navigates between pages without reloading the browser. Each Link points to a route path you defined in App.jsx. This navbar will appear on every page of your portfolio.
- Save Navbar.jsx.
The app won't run yet
Your App.jsx imports five page components that don't exist yet. If you start the dev server now, you'll see errors. That's expected. You'll create these pages in the next two steps.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here's what each file should look like at this point:
web/src/App.jsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Navbar from "./components/Navbar";
import Home from "./pages/Home";
import About from "./pages/About";
import Contact from "./pages/Contact";
import Data from "./pages/Data";
import Game from "./pages/Game";
import "./App.css";
function App() {
return (
<BrowserRouter>
<Navbar />
<main className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/data" element={<Data />} />
<Route path="/game" element={<Game />} />
</Routes>
</main>
</BrowserRouter>
);
}
export default App;
web/src/components/Navbar.jsx
import { Link } from "react-router-dom";
function Navbar() {
return (
<nav className="navbar">
<div className="navbar-brand">My Portfolio</div>
<ul className="navbar-links">
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/contact">Contact</Link></li>
<li><Link to="/data">Data</Link></li>
<li><Link to="/game">Game</Link></li>
</ul>
</nav>
);
}
export default Navbar;
web/src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
- Open the my-portfolio-project root folder in VS Code by clicking File in the top menu bar, then Open Folder, and selecting the my-portfolio-project folder on your Desktop.
You should see both README.md and the web/ folder in the VS Code Explorer sidebar.
Your monorepo structure is in place and the web app skeleton is ready. Next up, you'll build the Home, About, and Contact pages to bring your portfolio to life.
Build the Home, About, and Contact Pages
Your routing structure is in place and the dev server is running. Right now, clicking any of the navigation links shows a blank page because the page components don't exist yet.
In this step, you'll bring three pages to life. Each page introduces a different React pattern: internal navigation links, rendering lists from arrays, and handling form state.
In this step, get ready to:
- Build the Home page with internal navigation links.
- Build the About page with a skills list and an external link.
- Build the Contact page with text fields, radio buttons, checkboxes, and a submit handler.
Build the Home page
The Home page is the first thing visitors see. It needs a welcoming introduction and clear links to help them explore other pages.
- In the VS Code Explorer sidebar, right-click the web/src folder.
- Select New Folder and name it pages.
- Right-click the new pages folder, select New File, and name it Home.jsx.
- Paste the following code into Home.jsx:
import { Link } from "react-router-dom";
function Home() {
return (
<div className="page">
<h1>Welcome to My Portfolio</h1>
<p className="intro">
Hi! I'm a developer passionate about building web and mobile applications.
Explore my work and get in touch!
</p>
<div className="home-links">
<Link to="/about" className="btn">Learn About Me</Link>
<Link to="/contact" className="btn">Get In Touch</Link>
<Link to="/game" className="btn">Play a Game</Link>
</div>
</div>
);
}
export default Home;
What does this code do?
This component imports Link from react-router-dom to create navigation links that switch pages without a full browser reload.
The to prop works like an href in a regular anchor tag, but it uses client-side routing. The page swaps instantly without fetching a new HTML document from the server.
- Save Home.jsx.
- Switch to your browser and navigate to http://localhost:5173. You should see the heading "Welcome to My Portfolio" and three blue button-style links.
Still seeing a blank page?
- Make sure the file is saved inside web/src/pages/Home.jsx. A misplaced file won't match the import path in App.jsx.
- Check the terminal running your dev server for any red error messages. A typo in the file name or a missing export causes import failures.
- Why is my Home page not rendering?
Build the About page
The About page showcases your skills by rendering an array of strings as a list. It also demonstrates how to link to external websites safely.
- Create a new file called About.jsx inside the web/src/pages folder.
- Paste the following code into About.jsx:
function About() {
const skills = ["React", "React Native", "JavaScript", "TypeScript", "Node.js", "CSS"];
return (
<div className="page">
<h1>About Me</h1>
<p>
I'm a full-stack developer who enjoys creating both web and mobile applications.
I'm currently learning React Native and cross-platform development.
</p>
<h2>Skills</h2>
<ul className="skills-list">
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
<h2>Resources</h2>
<p>
I learned React from the{" "}
<a href="https://react.dev" target="_blank" rel="noopener noreferrer">
official React documentation
</a>
. It's a great place to start!
</p>
</div>
);
}
export default About;
What does this code do?
- skills.map() loops through the array and returns a <li> element for each skill. The key prop helps React track which items changed when the list updates.
- The external link uses a regular <a> tag with target="_blank" to open in a new tab. The rel="noopener noreferrer" attribute is a security best practice that prevents the new page from accessing your window object.
When do I use Link vs a regular anchor tag?
Use React Router's Link component for navigation between pages inside your app. Use a regular <a> tag for links that go to external websites.
- Save About.jsx.
- In your browser, click About in the navbar. You should see the heading "About Me", a bulleted list of six skills, and a clickable link to the React documentation.
Skills list not appearing?
- Confirm the file is named About.jsx with a capital A. File names are case-sensitive in imports.
- Check that you have export default About at the bottom of the file. Without the export, the route can't find the component.
- Help me debug my About page not rendering.
Build the Contact page with a form
The Contact page is the most complex component so far. It uses React state to track every field in the form and displays a thank-you message after submission.
- Create a new file called Contact.jsx inside the web/src/pages folder.
- Paste the following code into Contact.jsx:
import { useState } from "react";
function Contact() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
inquiryType: "general",
contactMethods: [],
});
const [submitted, setSubmitted] = useState(false);
function handleChange(e) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
function handleCheckbox(e) {
const { value, checked } = e.target;
setFormData((prev) => ({
...prev,
contactMethods: checked
? [...prev.contactMethods, value]
: prev.contactMethods.filter((m) => m !== value),
}));
}
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
}
if (submitted) {
return (
<div className="page">
<h1>Thank You!</h1>
<p>Thanks for reaching out, {formData.name}! I'll get back to you soon.</p>
</div>
);
}
return (
<div className="page">
<h1>Contact Me</h1>
<form onSubmit={handleSubmit} className="contact-form">
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows="4"
required
/>
</div>
<div className="form-group">
<label>Inquiry Type</label>
<div className="radio-group">
<label>
<input
type="radio"
name="inquiryType"
value="general"
checked={formData.inquiryType === "general"}
onChange={handleChange}
/>
General
</label>
<label>
<input
type="radio"
name="inquiryType"
value="feedback"
checked={formData.inquiryType === "feedback"}
onChange={handleChange}
/>
Feedback
</label>
<label>
<input
type="radio"
name="inquiryType"
value="job"
checked={formData.inquiryType === "job"}
onChange={handleChange}
/>
Job Opportunity
</label>
</div>
</div>
<div className="form-group">
<label>Preferred Contact Method</label>
<div className="checkbox-group">
<label>
<input
type="checkbox"
value="email"
checked={formData.contactMethods.includes("email")}
onChange={handleCheckbox}
/>
Email
</label>
<label>
<input
type="checkbox"
value="phone"
checked={formData.contactMethods.includes("phone")}
onChange={handleCheckbox}
/>
Phone
</label>
</div>
</div>
<button type="submit" className="btn">Submit</button>
</form>
</div>
);
}
export default Contact;
What does this code do?
- formData is a state object holding every form field's value. Using one object keeps all form data together instead of having separate useState calls for each field.
- handleChange handles text inputs and radio buttons. It uses the input's name attribute as the object key, so one function works for multiple fields.
- handleCheckbox adds or removes values from the contactMethods array depending on whether the checkbox was checked or unchecked.
- The if (submitted) block is conditional rendering. When the user submits the form, React swaps out the entire form and shows a thank-you message instead.
What are controlled components?
Each input's value is tied to React state, and every keystroke triggers onChange to update that state. This makes React the single source of truth for the form data.
The pattern gives you full control over validation, formatting, and conditional logic at any point during user interaction.
- Save Contact.jsx.
- In your browser, click Contact in the navbar. You should see a form with Name, Email, and Message fields, three radio buttons for inquiry type, two checkboxes for contact method, and a Submit button.
- Fill in all the fields and click Submit. You should see a "Thank You!" message that includes the name you entered.
Form not submitting or fields not updating?
- If typing in a field doesn't show your text, check that onChange={handleChange} is present on each input. Without it, the controlled input stays empty.
- If the page refreshes on submit instead of showing the thank-you message, confirm that e.preventDefault() is inside handleSubmit. This prevents the browser's default form submission behavior.
- Help me debug my Contact form not working.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files in web/src/pages/ and that clicking each navbar link shows the correct page.
ⓧ I'd like to double check the full code
Here are the three page files. Compare yours against these:
import { Link } from "react-router-dom";
function Home() {
return (
<div className="page">
<h1>Welcome to My Portfolio</h1>
<p className="intro">
Hi! I'm a developer passionate about building web and mobile applications.
Explore my work and get in touch!
</p>
<div className="home-links">
<Link to="/about" className="btn">Learn About Me</Link>
<Link to="/contact" className="btn">Get In Touch</Link>
<Link to="/game" className="btn">Play a Game</Link>
</div>
</div>
);
}
export default Home;
function About() {
const skills = ["React", "React Native", "JavaScript", "TypeScript", "Node.js", "CSS"];
return (
<div className="page">
<h1>About Me</h1>
<p>
I'm a full-stack developer who enjoys creating both web and mobile applications.
I'm currently learning React Native and cross-platform development.
</p>
<h2>Skills</h2>
<ul className="skills-list">
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
<h2>Resources</h2>
<p>
I learned React from the{" "}
<a href="https://react.dev" target="_blank" rel="noopener noreferrer">
official React documentation
</a>
. It's a great place to start!
</p>
</div>
);
}
export default About;
import { useState } from "react";
function Contact() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
inquiryType: "general",
contactMethods: [],
});
const [submitted, setSubmitted] = useState(false);
function handleChange(e) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
function handleCheckbox(e) {
const { value, checked } = e.target;
setFormData((prev) => ({
...prev,
contactMethods: checked
? [...prev.contactMethods, value]
: prev.contactMethods.filter((m) => m !== value),
}));
}
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
}
if (submitted) {
return (
<div className="page">
<h1>Thank You!</h1>
<p>Thanks for reaching out, {formData.name}! I'll get back to you soon.</p>
</div>
);
}
return (
<div className="page">
<h1>Contact Me</h1>
<form onSubmit={handleSubmit} className="contact-form">
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows="4"
required
/>
</div>
<div className="form-group">
<label>Inquiry Type</label>
<div className="radio-group">
<label>
<input
type="radio"
name="inquiryType"
value="general"
checked={formData.inquiryType === "general"}
onChange={handleChange}
/>
General
</label>
<label>
<input
type="radio"
name="inquiryType"
value="feedback"
checked={formData.inquiryType === "feedback"}
onChange={handleChange}
/>
Feedback
</label>
<label>
<input
type="radio"
name="inquiryType"
value="job"
checked={formData.inquiryType === "job"}
onChange={handleChange}
/>
Job Opportunity
</label>
</div>
</div>
<div className="form-group">
<label>Preferred Contact Method</label>
<div className="checkbox-group">
<label>
<input
type="checkbox"
value="email"
checked={formData.contactMethods.includes("email")}
onChange={handleCheckbox}
/>
Email
</label>
<label>
<input
type="checkbox"
value="phone"
checked={formData.contactMethods.includes("phone")}
onChange={handleCheckbox}
/>
Phone
</label>
</div>
</div>
<button type="submit" className="btn">Submit</button>
</form>
</div>
);
}
export default Contact;
Three pages are live and interactive. Next up, you'll build the Data page with a styled table and a fully playable Tic-Tac-Toe game.
Build the Data Table and Tic-Tac-Toe Game Pages
Your portfolio now has a Home page, About page, and a Contact page with a fully working form. The next two pages bring interactivity and structured data into the mix.
In this step, you will build a Data page that renders project information in an HTML table, and a Game page with a fully playable Tic-Tac-Toe game. You will also add a complete stylesheet that brings all five pages to life visually.
In this step, get ready to:
- Build a Data page with a styled HTML table.
- Build an interactive Tic-Tac-Toe game page.
- Add game reset and winner display.
Build the Data page with a table
Tables are one of the best ways to present structured, scannable information on a web page. Your Data page will display a list of projects with their technology stack, status, and year.
- Create a new file called Data.jsx inside web/src/pages/.
- Paste the following code into Data.jsx:
function Data() {
// Array of project objects to display in the table
const projects = [
{ name: "Portfolio Website", tech: "React, Vite", status: "Completed", year: 2026 },
{ name: "Greeting App", tech: "React Native, Expo", status: "Completed", year: 2026 },
{ name: "Weather Dashboard", tech: "React, API", status: "In Progress", year: 2026 },
{ name: "Task Manager", tech: "Node.js, Express", status: "Planned", year: 2026 },
{ name: "E-Commerce Store", tech: "Next.js, Stripe", status: "Planned", year: 2026 },
];
return (
<div className="page">
<h1>My Projects</h1>
<p>Here's a summary of my recent and upcoming projects:</p>
{/* HTML table with thead for column headers and tbody for data rows */}
<table className="data-table">
<thead>
<tr>
<th>Project Name</th>
<th>Technology</th>
<th>Status</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.name}>
<td>{project.name}</td>
<td>{project.tech}</td>
<td>{project.status}</td>
<td>{project.year}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default Data;
What does this code do?
The projects array holds 5 objects, each representing a project with a name, technology, status, and year.
The projects.map() call loops through the array and renders a table row for each project. The key={project.name} prop helps React efficiently update the list if it changes.
- Save Data.jsx.
- Return to your browser and click Data in the navbar. You should see a heading, a short paragraph, and a table with 5 rows of project data.
Don't see the table?
Make sure the file is saved as Data.jsx (capital D) inside the src/pages/ folder. The import in App.jsx expects that exact path and casing.
help me debug why my Data page isn't rendering
The table works, but everything still looks unstyled. Next, you will add a stylesheet that covers all five pages at once.
- Open web/src/App.css in your editor. This file already exists from the Vite scaffold.
- Replace the entire contents of the file with the following CSS:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.navbar {
background: #1a1a2e;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
color: #fff;
font-size: 1.4rem;
font-weight: bold;
}
.navbar-links {
list-style: none;
display: flex;
gap: 1.5rem;
}
.navbar-links a {
color: #e0e0e0;
text-decoration: none;
font-size: 1rem;
}
.navbar-links a:hover {
color: #4fc3f7;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.page h1 {
margin-bottom: 1rem;
color: #1a1a2e;
}
.page h2 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.intro {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.home-links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 0.6rem 1.2rem;
background: #1a1a2e;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 1rem;
}
.btn:hover {
background: #16213e;
}
.skills-list {
list-style: disc;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.contact-form {
max-width: 500px;
}
.form-group {
margin-bottom: 1.2rem;
}
.form-group label {
display: block;
margin-bottom: 0.3rem;
font-weight: 600;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.radio-group,
.checkbox-group {
display: flex;
gap: 1rem;
margin-top: 0.3rem;
}
.radio-group label,
.checkbox-group label {
font-weight: normal;
display: flex;
align-items: center;
gap: 0.3rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.data-table th,
.data-table td {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.data-table th {
background: #1a1a2e;
color: #fff;
}
.data-table tr:nth-child(even) {
background: #f9f9f9;
}
.game-status {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 1rem;
}
.game-board {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 4px;
margin-bottom: 1rem;
}
.game-square {
width: 100px;
height: 100px;
font-size: 2rem;
font-weight: bold;
background: #f0f0f0;
border: 2px solid #1a1a2e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.game-square:hover {
background: #e0e0e0;
}
.reset-btn {
margin-top: 0.5rem;
}
What does this stylesheet cover?
This single file styles the entire portfolio. It includes a dark navbar, centered page container, button styles, form layouts, the data table with alternating row colours, and the 3x3 game grid you will build next.
The .data-table th rule gives table headers a dark background. The .game-board uses CSS Grid to create a 3-column layout for the Tic-Tac-Toe squares.
- Save App.css.
- Refresh your browser. You should see a dark navbar across the top, styled buttons on the Home page, and a neatly formatted table on the Data page with a dark header row and alternating grey stripes.
Styles not showing up?
Check that App.jsx still has import "./App.css" near the top. Without that import, Vite won't include the stylesheet.
Also make sure you replaced the entire file contents. Leftover default Vite styles can conflict with your new rules.
help me fix my CSS not applying
Build the Tic-Tac-Toe game page
A game component is a great way to practise React state management. The board is a 9-element array, clicks toggle between X and O, and a helper function checks all 8 possible winning lines after every move.
- Create a new file called Game.jsx inside web/src/pages/.
- Paste the following code into Game.jsx:
import { useState } from "react";
function Game() {
// Board is a 9-element array, each cell is null, "X", or "O"
const [board, setBoard] = useState(Array(9).fill(null));
const [isXTurn, setIsXTurn] = useState(true);
// Check all 8 possible winning lines
function calculateWinner(squares) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
for (const [a, b, c] of lines) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
const winner = calculateWinner(board);
const isDraw = !winner && board.every((square) => square !== null);
// Place X or O in the clicked cell
function handleClick(index) {
if (board[index] || winner) return;
const newBoard = [...board];
newBoard[index] = isXTurn ? "X" : "O";
setBoard(newBoard);
setIsXTurn(!isXTurn);
}
// Determine the status message
let status;
if (winner) {
status = `Winner: ${winner}!`;
} else if (isDraw) {
status = "It's a draw!";
} else {
status = `Next player: ${isXTurn ? "X" : "O"}`;
}
return (
<div className="page">
<h1>Tic-Tac-Toe</h1>
<p className="game-status">{status}</p>
<div className="game-board">
{board.map((square, index) => (
<button key={index} className="game-square" onClick={() => handleClick(index)}>
{square}
</button>
))}
</div>
</div>
);
}
export default Game;
What does this code do?
- board is a 9-element array where each cell starts as null and becomes "X" or "O" when clicked.
- calculateWinner loops through all 8 winning combinations (3 rows, 3 columns, 2 diagonals) and returns the winning symbol if a line matches.
- handleClick ignores clicks on occupied squares or after a winner is found. Otherwise it places the current player's mark and flips the turn.
- The status variable updates dynamically to show the winner, a draw, or whose turn is next.
- Save Game.jsx.
- Click Game in the navbar. You should see a 3x3 grid of square buttons and a status message reading Next player: X.
- Click a few squares to confirm turns alternate between X and O.
Seeing a blank page instead of the grid?
Check your terminal for any syntax errors. A missing curly brace or parenthesis in the board.map() return block is the most common cause.
Also confirm the file is named Game.jsx with a capital G, matching the import in App.jsx.
help me fix my Tic-Tac-Toe game not rendering
Add game reset and winner display
Right now, once a game ends, you have to refresh the entire page to play again. A reset button lets players start a new round instantly.
- In Game.jsx, find the closing brace of the handleClick function. Add the following resetGame function directly below it:
// Reset the board to start a new game
function resetGame() {
setBoard(Array(9).fill(null));
setIsXTurn(true);
}
- Next, find the closing </div> tag that closes the game-board div. Add this button directly below it:
<button onClick={resetGame} className="btn reset-btn">Reset Game</button>
What does resetGame do?
It resets board back to 9 null values and sets isXTurn back to true so X always goes first in a new game. Because React re-renders when state changes, the board clears instantly.
- Save Game.jsx.
- Refresh the Game page. You should see a Reset Game button below the grid.
- Play a full game until someone wins. Confirm the status changes to Winner: X! or Winner: O! and clicking further squares does nothing.
- Click Reset Game and verify the board clears completely.
- Fill every square without a winner. Confirm the status shows It's a draw!.
Reset button not working?
Make sure the button uses onClick={resetGame} without parentheses. Writing onClick={resetGame()} calls the function immediately on render instead of on click.
help me debug my Reset Game button
✔️ Awesome, I've got everything!
Great. Double check you have saved both Data.jsx, Game.jsx, and App.css.
ⓧ I'd like to double check the full code
Here is the complete code for each file you created or edited in this step.
function Data() {
const projects = [
{ name: "Portfolio Website", tech: "React, Vite", status: "Completed", year: 2026 },
{ name: "Greeting App", tech: "React Native, Expo", status: "Completed", year: 2026 },
{ name: "Weather Dashboard", tech: "React, API", status: "In Progress", year: 2026 },
{ name: "Task Manager", tech: "Node.js, Express", status: "Planned", year: 2026 },
{ name: "E-Commerce Store", tech: "Next.js, Stripe", status: "Planned", year: 2026 },
];
return (
<div className="page">
<h1>My Projects</h1>
<p>Here's a summary of my recent and upcoming projects:</p>
<table className="data-table">
<thead>
<tr>
<th>Project Name</th>
<th>Technology</th>
<th>Status</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.name}>
<td>{project.name}</td>
<td>{project.tech}</td>
<td>{project.status}</td>
<td>{project.year}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default Data;
import { useState } from "react";
function Game() {
const [board, setBoard] = useState(Array(9).fill(null));
const [isXTurn, setIsXTurn] = useState(true);
function calculateWinner(squares) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
for (const [a, b, c] of lines) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
const winner = calculateWinner(board);
const isDraw = !winner && board.every((square) => square !== null);
function handleClick(index) {
if (board[index] || winner) return;
const newBoard = [...board];
newBoard[index] = isXTurn ? "X" : "O";
setBoard(newBoard);
setIsXTurn(!isXTurn);
}
function resetGame() {
setBoard(Array(9).fill(null));
setIsXTurn(true);
}
let status;
if (winner) {
status = `Winner: ${winner}!`;
} else if (isDraw) {
status = "It's a draw!";
} else {
status = `Next player: ${isXTurn ? "X" : "O"}`;
}
return (
<div className="page">
<h1>Tic-Tac-Toe</h1>
<p className="game-status">{status}</p>
<div className="game-board">
{board.map((square, index) => (
<button key={index} className="game-square" onClick={() => handleClick(index)}>
{square}
</button>
))}
</div>
<button onClick={resetGame} className="btn reset-btn">Reset Game</button>
</div>
);
}
export default Game;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.navbar {
background: #1a1a2e;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-brand {
color: #fff;
font-size: 1.4rem;
font-weight: bold;
}
.navbar-links {
list-style: none;
display: flex;
gap: 1.5rem;
}
.navbar-links a {
color: #e0e0e0;
text-decoration: none;
font-size: 1rem;
}
.navbar-links a:hover {
color: #4fc3f7;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.page h1 {
margin-bottom: 1rem;
color: #1a1a2e;
}
.page h2 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.intro {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.home-links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 0.6rem 1.2rem;
background: #1a1a2e;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 1rem;
}
.btn:hover {
background: #16213e;
}
.skills-list {
list-style: disc;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.contact-form {
max-width: 500px;
}
.form-group {
margin-bottom: 1.2rem;
}
.form-group label {
display: block;
margin-bottom: 0.3rem;
font-weight: 600;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.radio-group,
.checkbox-group {
display: flex;
gap: 1rem;
margin-top: 0.3rem;
}
.radio-group label,
.checkbox-group label {
font-weight: normal;
display: flex;
align-items: center;
gap: 0.3rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.data-table th,
.data-table td {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.data-table th {
background: #1a1a2e;
color: #fff;
}
.data-table tr:nth-child(even) {
background: #f9f9f9;
}
.game-status {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 1rem;
}
.game-board {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 4px;
margin-bottom: 1rem;
}
.game-square {
width: 100px;
height: 100px;
font-size: 2rem;
font-weight: bold;
background: #f0f0f0;
border: 2px solid #1a1a2e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.game-square:hover {
background: #e0e0e0;
}
.reset-btn {
margin-top: 0.5rem;
}
All five pages of your portfolio are now complete and styled. Next up, you will push this code to GitHub and deploy the live site to Render.com.
Deploy the Web App to Render.com
Your 5-page portfolio is working locally, but no one else can see it yet. In this step, you'll push your code to GitHub and deploy it as a live website on Render.com.
Render hosts static sites for free on a global CDN. It connects directly to your GitHub repository and automatically rebuilds your site whenever you push new code.
In this step, get ready to:
- Push the monorepo to a new GitHub repository.
- Deploy the web app as a static site on Render.com.
- Configure client-side routing so all 5 pages work at their direct URLs.
Push the monorepo to GitHub
Before deploying, you need your code in a remote repository that Render can access. You'll create a root .gitignore file to keep unnecessary files out of the repository, then push everything to GitHub.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Navigate back to the project root by running this command:
cd ..
You should now be in the my-portfolio-project folder (the monorepo root).
- Confirm you're in the right place by running:
ls
You should see README.md and the web folder listed.
- Create a .gitignore file in the project root by running this command:
echo node_modules > .gitignore && echo dist >> .gitignore
What does this command do?
The .gitignore file tells Git which files and folders to exclude from version control.
- node_modules contains installed packages (thousands of files that anyone can regenerate with npm install).
- dist contains the production build output (Render generates this during deployment).
- Stage all files and create your first commit by running these commands:
git add .
git commit -m "Initial commit with web portfolio"
You should see a list of files being committed to the repository.
- Open your browser and go to github.com.
- Sign in to your GitHub account.
- Click the + icon in the top-right corner, then click New repository.
- Enter my-portfolio-project as the repository name.
- Leave all other settings as default (Public is fine).
- Click Create repository.
GitHub will show a page with setup instructions. Look for the section titled "...or push an existing repository from the command line".
- Copy and run the commands GitHub shows you. They will look similar to this:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/my-portfolio-project.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin connects your local repository to the GitHub repository you just created.
- git branch -M main renames your default branch to main.
- git push -u origin main uploads all your code to GitHub.
- Refresh your GitHub repository page in the browser.
You should see your README.md, .gitignore, and web/ folder listed in the repository.
Push failed or permission denied?
If you see a permission error, make sure you're signed in to the correct GitHub account in your terminal. You may need to set up a personal access token or SSH key if you haven't pushed to GitHub from this computer before.
help me fix a GitHub push authentication error
Deploy on Render.com
With your code on GitHub, Render can now pull it and build your site automatically.
- Open render.com in your browser.
- Click Get Started to sign up for a free account (or sign in if you already have one).
- Connect your GitHub account when prompted.
Once signed in, you'll see the Render Dashboard.
- Click New in the top navigation.
- Click Static Site.
- Select your my-portfolio-project repository from the list.
Don't see your repository?
If your repository doesn't appear, click the link to configure your GitHub connection. Grant Render access to the specific repository or all repositories, then return to this page and refresh.
Render shows a form asking for your build settings. Since this is a monorepo, you need to point Render at the web/ subfolder.
- Set the Root Directory field to web.
- Set the Build Command field to npm install && npm run build.
- Set the Publish Directory field to dist.
- Click Create Static Site.
What do these settings mean?
- Root Directory tells Render to run the build from the web/ subfolder instead of the repository root.
- Build Command installs your dependencies and creates an optimized production build using Vite.
- Publish Directory points to dist, the folder Vite outputs the final HTML, CSS, and JavaScript to.
Render will now build and deploy your site. This takes 1-2 minutes. You'll see a log stream showing the build progress.
Build failed?
Check that you set the Root Directory to web (not web/ with a trailing slash). Also verify that the Build Command is exactly npm install && npm run build.
help me debug a Render.com static site build failure
Configure client-side routing
Your site is live, but there's one problem. If you navigate to a page like /about and then refresh the browser, you'll get a "Not Found" error.
Why does refreshing a page break?
React Router handles navigation on the client side. When you click a link within the app, JavaScript updates the URL and renders the correct component without asking the server for anything.
But when you refresh the browser or navigate directly to /about, the server receives that request. Since no about.html file exists on the server, it returns a 404. A rewrite rule tells the server to serve index.html for all paths, letting React Router take over.
- In the Render Dashboard, click on your static site service to open its settings.
- Click the Redirects/Rewrites tab.
- Click Add Rule.
- Set Source to /*.
- Set Destination to /index.html.
- Set Action to Rewrite.
- Click Save Changes.
- Open your site's public URL (the .onrender.com address shown on your dashboard).
- Navigate to the About page using the navbar.
- Refresh the browser while on the About page.
The page should load correctly without a 404 error. All 5 pages (Home, About, Contact, Data, Game) now work at their direct URLs.
Still seeing a Not Found error?
Double-check that the Source is /* (with the forward slash) and the Destination is /index.html. Make sure the Action is set to Rewrite (not Redirect). The rule takes effect immediately after saving.
help me fix client-side routing on Render.com
Your portfolio website is now live on the internet. Next up, you'll create the React Native mobile app in the same repository and style it with NativeWind.
Create the Expo Project and Set Up NativeWind
Your portfolio website is live on the internet. Now it's time to build the mobile side of your monorepo.
In this step, you'll scaffold a React Native app using Expo and configure NativeWind so you can style your mobile app with Tailwind CSS utility classes.
In this step, get ready to:
- Scaffold an Expo React Native project inside your monorepo.
- Install NativeWind and its Tailwind CSS dependencies.
- Configure Tailwind, Babel, Metro, and the root layout for NativeWind styling.
Create the Expo project in the monorepo
Your monorepo already has the web/ folder. You'll now add a mobile/ folder containing the React Native app.
- Make sure your terminal is in the my-portfolio-project root folder (the one containing web/).
- Create the Expo project by running this command:
npx create-expo-app@latest mobile
What does this command do?
This scaffolds a new React Native project inside a folder called mobile/. It includes Expo CLI, Expo Router for file-based navigation, and TypeScript configured by default.
- Move into the new project folder by running:
cd mobile
The default template includes boilerplate code you don't need. The reset-project script clears it out and gives you a clean starting point.
- Remove the boilerplate by running:
npm run reset-project
What does reset-project do?
This moves all the example boilerplate into an app-example/ folder and leaves only app/index.tsx and app/_layout.tsx in your app/ directory. You can delete app-example/ later if you like.
- Confirm the project structure by running:
ls app
You should see index.tsx and _layout.tsx listed.
Don't see those files?
Make sure you ran npm run reset-project from inside the mobile/ folder. If you answered a confirmation prompt with "no", run the command again and confirm.
Help me troubleshoot my Expo project setup
Install NativeWind and dependencies
NativeWind lets you use Tailwind CSS utility classes directly on React Native components. It compiles Tailwind styles into native StyleSheet objects at build time, so you get the convenience of Tailwind with native performance.
- Install NativeWind and its peer dependencies by running:
npm install nativewind react-native-reanimated react-native-safe-area-context
What are these packages?
NativeWind is the bridge between Tailwind CSS and React Native. react-native-reanimated powers smooth animations, and react-native-safe-area-context handles safe area insets on devices with notches.
- Install the dev dependencies (Tailwind CSS and the Babel preset) by running:
npm install --dev tailwindcss@^3.4.17 babel-preset-expo
- You should see a success message after each install with no errors.
Seeing peer dependency warnings?
Peer dependency warnings are common in React Native projects and usually safe to ignore. As long as you don't see an ERR! message, your install succeeded.
Help me fix my NativeWind dependency installation errors
Configure Tailwind, Babel, and Metro
NativeWind needs several configuration files to connect Tailwind CSS with React Native's build system. You'll create each one now.
- Create a new file called tailwind.config.js in the mobile/ folder and paste the following code:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};
What does this config do?
The content array tells Tailwind which files to scan for class names. The nativewind/preset adds React Native-specific utilities and makes Tailwind's output compatible with native platforms.
- Save tailwind.config.js.
- Open babel.config.js in the mobile/ folder (it already exists from the scaffold).
- Replace the entire contents with this code:
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
What changed in the Babel config?
The jsxImportSource: "nativewind" option tells the JSX transform to use NativeWind's runtime, which adds className support to all React Native components. The nativewind/babel preset handles the style compilation.
- Save babel.config.js.
- Create a new file called metro.config.js in the mobile/ folder and paste this code:
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
What does Metro config do?
Metro is React Native's JavaScript bundler. The withNativeWind wrapper tells Metro to process your global.css file through Tailwind's compiler and include the generated styles in your app bundle.
- Save metro.config.js.
- Create a new file called global.css in the mobile/ folder and paste these Tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;
- Save global.css.
- Create a new file called nativewind-env.d.ts in the mobile/ folder and paste this TypeScript reference:
/// <reference types="nativewind/types" />
What is this file for?
This TypeScript declaration file tells your editor that React Native components accept a className prop. Without it, TypeScript would show errors whenever you use Tailwind classes.
- Save nativewind-env.d.ts.
Next, you need to tell Expo to use Metro as the web bundler so NativeWind styles work when testing in the browser.
- Open app.json in the mobile/ folder.
- Find the "web" key inside the "expo" object.
- Add "bundler": "metro" inside the "web" object so it looks like this:
"web": {
"bundler": "metro",
"favicon": "./assets/images/favicon.png"
}
- Save app.json.
Finally, you need to import the global CSS in your root layout so NativeWind styles load when the app starts.
- Open app/_layout.tsx in the mobile/ folder.
- Replace the entire contents with this code:
import "../global.css";
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: true, title: "Greeting App" }} />
);
}
What does this layout do?
The import "../global.css" line loads Tailwind styles into the app. The Stack component from Expo Router provides stack-based navigation with a header bar titled "Greeting App".
- Save app/_layout.tsx.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your config files before moving on.
ⓧ I'd like to double check the full code
Here are the complete files you should have in your mobile/ folder:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
@tailwind base;
@tailwind components;
@tailwind utilities;
/// <reference types="nativewind/types" />
import "../global.css";
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: true, title: "Greeting App" }} />
);
}
Your mobile project is scaffolded and NativeWind is fully configured. Next up, you'll build the greeting app screen and see those Tailwind classes in action on your phone.
Build the Greeting App with NativeWind Styling
Your NativeWind configuration is ready. The mobile/ folder has Tailwind, Babel, and Metro all wired up so that you can use className props on React Native components.
Now it is time to build the actual greeting screen. You will create a simple but polished app that accepts a user's name, validates input, and displays a personalized greeting. All styling uses NativeWind utility classes instead of StyleSheet.create.
In this step, get ready to:
- Build a greeting screen with a text input, button, and dynamic message.
- Add input validation with visual error feedback.
- Test the app on your phone with Expo Go and in the browser.
Build the greeting screen
The greeting app is a single screen with three interactive pieces: a text input for the user's name, a button that triggers the greeting, and a text area that shows the result. React Native provides dedicated components for each of these.
- In VS Code, open mobile/app/index.tsx.
- Select all the existing content and replace it with the following code:
import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native";
export default function HomeScreen() {
const [name, setName] = useState("");
const [greeting, setGreeting] = useState("");
const [error, setError] = useState("");
function handleGreet() {
if (name.trim() === "") {
setError("Please enter your name!");
setGreeting("");
} else {
setGreeting(`Hello, ${name.trim()}! Welcome to the app!`);
setError("");
}
}
return (
<View className="flex-1 items-center justify-center bg-gray-100 p-6">
<Text className="text-3xl font-bold text-gray-900 mb-2">Greeting App</Text>
<Text className="text-base text-gray-500 mb-6">Enter your name below</Text>
<TextInput
className={`w-full max-w-xs p-3 border rounded-lg bg-white text-base mb-2 ${
error ? "border-red-500" : "border-gray-300"
}`}
placeholder="Your name"
value={name}
onChangeText={(text) => {
setName(text);
setError("");
}}
/>
{error ? <Text className="text-red-500 text-sm mb-3">{error}</Text> : null}
<Pressable
className="bg-gray-900 py-3 px-8 rounded-lg mt-2 active:bg-gray-700"
onPress={handleGreet}
>
<Text className="text-white text-base font-bold">Greet Me</Text>
</Pressable>
{greeting ? (
<Text className="mt-6 text-lg text-green-600 font-semibold text-center">
{greeting}
</Text>
) : null}
</View>
);
}
What does this code do?
This file creates the entire greeting screen. Three useState hooks track the user's typed name, the greeting message, and any error message.
The handleGreet function checks whether the input is empty. If it is, it sets an error. Otherwise, it builds a personalized greeting string.
Every component uses className props with Tailwind utility classes. NativeWind translates these into native styles at build time, so bg-gray-100 becomes a real background colour on your phone.
- Save mobile/app/index.tsx.
Seeing red underlines in VS Code?
If TypeScript complains about className not existing on React Native components, make sure nativewind-env.d.ts exists in the mobile/ folder. That file adds the type definitions NativeWind needs.
Why isn't className recognized on my React Native components?
Understand the empty input validation
The code you just pasted already includes validation logic. Let's walk through how it protects against empty submissions.
How does the validation work?
- When the user presses the button with an empty input, handleGreet sets the error state to "Please enter your name!".
- The TextInput border colour changes dynamically: border-red-500 when there is an error, border-gray-300 otherwise. This uses a template literal inside the className prop.
- The error text renders conditionally with {error ? <Text>...</Text> : null}. It only appears when error is non-empty.
- As soon as the user starts typing again, onChangeText clears the error so the red border disappears immediately.
Test on Expo Go
You can preview the app instantly on your physical phone using Expo Go. The development server generates a QR code that connects your phone to the code running on your computer.
- Make sure your terminal is inside the mobile/ folder.
- Start the Expo development server by running:
npx expo start
- A QR code appears in your terminal along with a list of keyboard shortcuts.
- Open the Expo Go app on your phone.
- Scan the QR code displayed in the terminal. On Android, use the Expo Go scanner. On iOS, use your Camera app.
- The greeting app loads on your phone with NativeWind styling applied.
- Type your name and tap Greet Me. You should see a green greeting message appear below the button.
- Tap Greet Me with an empty input. You should see a red error message and a red border on the text field.
- Press w in your terminal to also open the app in a web browser for a quick preview.
What do the keyboard shortcuts do?
The Expo Terminal UI shows several shortcuts. Press w to open in a web browser. Press r to reload the app on any connected device. Press ? to see all available commands.
App not loading on your phone?
Make sure your phone and computer are on the same Wi-Fi network. The development server communicates over your local network.
If the QR code does not connect, try stopping the server with Ctrl+C and restarting with npx expo start --tunnel. This routes traffic through a tunnel instead of local Wi-Fi.
Help me connect Expo Go to my development server
✔️ Awesome, I've got everything!
Great. Double check you have saved mobile/app/index.tsx and that your greeting app works on your phone or in the browser.
ⓧ I'd like to double check the full code
Here is the complete mobile/app/index.tsx file:
import { useState } from "react";
import { View, Text, TextInput, Pressable } from "react-native";
export default function HomeScreen() {
const [name, setName] = useState("");
const [greeting, setGreeting] = useState("");
const [error, setError] = useState("");
function handleGreet() {
if (name.trim() === "") {
setError("Please enter your name!");
setGreeting("");
} else {
setGreeting(`Hello, ${name.trim()}! Welcome to the app!`);
setError("");
}
}
return (
<View className="flex-1 items-center justify-center bg-gray-100 p-6">
<Text className="text-3xl font-bold text-gray-900 mb-2">Greeting App</Text>
<Text className="text-base text-gray-500 mb-6">Enter your name below</Text>
<TextInput
className={`w-full max-w-xs p-3 border rounded-lg bg-white text-base mb-2 ${
error ? "border-red-500" : "border-gray-300"
}`}
placeholder="Your name"
value={name}
onChangeText={(text) => {
setName(text);
setError("");
}}
/>
{error ? <Text className="text-red-500 text-sm mb-3">{error}</Text> : null}
<Pressable
className="bg-gray-900 py-3 px-8 rounded-lg mt-2 active:bg-gray-700"
onPress={handleGreet}
>
<Text className="text-white text-base font-bold">Greet Me</Text>
</Pressable>
{greeting ? (
<Text className="mt-6 text-lg text-green-600 font-semibold text-center">
{greeting}
</Text>
) : null}
</View>
);
}
Your greeting app is fully functional with NativeWind styling and input validation. Next up, you will build an installable APK using EAS Build so you can share the app without needing Expo Go.
Build the APK with EAS Build
Your greeting app works perfectly in Expo Go, but Expo Go is a development tool. To share the app with others or install it permanently on a phone, you need a real APK file.
Building an APK normally requires Android Studio and the full Android SDK, which is a heavy setup. EAS Build solves this by compiling your app in the cloud. You send your code up, EAS builds the APK on its servers, and you get a download link back.
In this step, get ready to:
- Install and configure EAS CLI for cloud builds.
- Set up the build profile to produce an APK file.
- Build the APK in the cloud and install it on your phone.
Set up EAS
EAS CLI is the command-line tool that connects your local project to Expo's cloud build service. You need to install it globally and authenticate with an Expo account.
- If the Expo dev server is still running in your terminal, press Ctrl+C to stop it.
- Make sure you are still in the mobile folder. Install EAS CLI globally by running:
npm install -g eas-cli
What is EAS CLI?
EAS stands for Expo Application Services. The eas-cli tool lets you trigger cloud builds, manage credentials, and deploy updates from your terminal. It connects to Expo's servers so you never need Android Studio or Xcode installed locally.
- Create a free Expo account at expo.dev if you do not have one already.
- Log in to your Expo account from the terminal by running:
eas login
- EAS CLI will prompt you for your Expo username and password. Enter the credentials you used to sign up at expo.dev.
- Configure the project for EAS Build by running:
eas build:configure
- When prompted to select a platform, choose All (or Android if you only see that option).
What does this command do?
The eas build:configure command creates an eas.json file in your project root. This file defines build profiles that tell EAS how to compile your app for different purposes (development, preview, production).
- Confirm the file was created by running:
ls eas.json
- You should see eas.json listed in the output.
Command not found after installing EAS CLI?
If eas is not recognized, close your terminal and open a new one. Global npm packages sometimes need a fresh terminal session to appear in your PATH.
Help me fix my PATH for globally installed npm packages
Configure for APK output
The default eas.json already has a preview profile, but it does not specify the APK build type. Without this setting, EAS produces an AAB file (Android App Bundle) which can only be installed via Google Play. You need an APK for direct installation on a device.
- Open eas.json in VS Code.
- Replace its entire contents with the following configuration:
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
},
"production": {}
}
}
What does this configuration do?
The preview profile is the one you will use to build. It has two key settings:
- "distribution": "internal" tells EAS this build is for direct sharing (not for an app store).
- "android": { "buildType": "apk" } tells EAS to produce an installable APK file instead of the default AAB format.
- Save eas.json.
Seeing a JSON syntax error later during build?
Double check that every bracket and comma in eas.json matches the code above exactly. A missing comma after a closing brace is the most common mistake.
Help me check my eas.json syntax
Build the APK and commit everything
With EAS configured, you can now trigger a cloud build. The build runs entirely on Expo's servers, so you do not need Android Studio or any Android SDK installed.
- Start the APK build by running:
eas build -p android --profile preview
What happens during this build?
EAS uploads your project code to its cloud servers, installs dependencies, compiles the native Android code, signs the APK with an auto-generated keystore, and produces a downloadable file. The whole process takes about 5 to 15 minutes.
You can monitor progress in your terminal or on the EAS dashboard at expo.dev.
- EAS CLI will ask for your Android application ID. Press Enter to accept the default value.
- When asked to generate a new Android Keystore, press Y.
- Wait for the build to complete. When finished, EAS provides a download URL for your APK in the terminal output.
- Open the download URL on your Android phone's browser to download the APK file.
- Tap the downloaded APK file to install it. If prompted, enable installation from unknown sources in your phone's settings.
- Open the installed app and verify the greeting screen appears with the text input and Greet Me button.
Build failed or taking too long?
Builds on the free tier can sometimes queue behind other users. If your build fails, check the build logs on the EAS dashboard for specific errors. Common causes include uncommitted changes in git or missing dependencies.
Help me troubleshoot my EAS build failure
Now commit the mobile app and push the full monorepo to GitHub.
- Navigate back to the project root by running:
cd ..
- Stage and commit the mobile app, then push to GitHub by running these commands:
git add .
git commit -m "Add mobile greeting app with EAS build config"
git push
- Verify the push succeeded by checking your GitHub repository page. You should see both the web/ and mobile/ folders listed.
Git push rejected?
If git reports that the remote contains work you do not have locally, run git pull --rebase first, then try git push again.
Help me resolve a rejected git push
✔️ Awesome, I've got everything!
Great. Double check you have saved eas.json and pushed everything to GitHub.
ⓧ I'd like to double check the full code
Here is the complete eas.json file for reference:
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
},
"production": {}
}
}
Your mobile app is now a real installable APK, and both projects live in one GitHub repository. Next up, you will take on a bonus challenge to add real-time form validation to your Contact page.
Secret mission
Real-Time Form Validation
Your Contact page form works, but it only validates on submit. Real production forms give instant feedback as users type, highlighting problems before they ever hit the button. Can you add real-time validation that lights up invalid fields the moment the user interacts with them?
Clean Up Your Resources
Clean Up Your Resources
This project uses Render.com's free tier for the static site and EAS Build's free tier for the APK. There are no ongoing costs while your site is deployed on the free plan.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder (my-portfolio-project/ on your Desktop).
- Render.com static site (your .onrender.com URL).
- GitHub repository (your monorepo with web/ and mobile/ folders).
- EAS CLI (installed globally via npm).
Keep everything running
No action needed. Choose this if you're still actively building or want your portfolio site to stay live.
- Your Render.com static site stays live at your .onrender.com URL for free.
- Your GitHub repository remains available for future updates.
- The EAS CLI stays installed on your machine for future builds.
- Your local my-portfolio-project/ folder stays on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and deployed site stay intact.
- Stop the Vite dev server if it is still running by pressing Ctrl+C in the terminal.
- Stop the Expo dev server if it is still running by pressing Ctrl+C in the terminal.
- Your Render.com site stays live. No action needed there.
- Your GitHub repository and local files remain unchanged.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete the local project folder by running this command in PowerShell:
cd ~/Desktop
Remove-Item -Recurse -Force my-portfolio-project
- Delete the Render.com static site: open the Render Dashboard, click your static site, click Settings in the left pane, scroll to the bottom of the page, and click Delete Static Site.
- Delete the GitHub repository: go to your repository page on GitHub, click Settings, scroll down to the Danger Zone section, click Delete this repository, then type the repository name to confirm.
- Uninstall EAS CLI by running:
npm uninstall -g eas-cli
That's a Wrap!
That's a Wrap!
Nice work! You've built and deployed a full React portfolio website AND a React Native mobile greeting app, all from a single monorepo repository.
You've learned how to:
- Built a multi-page React app with client-side routing, forms, tabular data, and an interactive Tic-Tac-Toe game, then deployed it to Render.com as a live static site.
- Configured NativeWind (Tailwind CSS for React Native) with Metro, Babel, and a global stylesheet to style a greeting app using utility classes.
- Generated an installable Android APK using EAS Build in the cloud, with no Android Studio required.
- Added real-time form validation with visual feedback, inline error messages, and a disabled submit button until all fields are valid.
Ready to quiz yourself?