Part 96: Building a Salesforce Portfolio Experience Cloud Site
Welcome back to the Salesforce series, and welcome to the final post in Topic 3. Over the last twenty-four sections we have built up a deep understanding of Lightning Web Components. We started with the fundamentals of HTML (Part 72), CSS (Part 73), JavaScript (Part 71), and the DOM (Part 70). We moved into the core of LWC itself — data binding and templates (Part 74), events (Part 75), lifecycle hooks and decorators (Part 76), styling (Part 77), retrieving data from Apex (Part 78), the @wire adapter (Part 79), the Lightning Data Service (Part 83), navigation (Part 88), flows integration (Part 89), quick actions (Part 90), and so much more. Every one of those posts gave you a piece of the puzzle. Now it is time to put them all together.
In this final LWC section we are going to build a complete Salesforce Portfolio site on Experience Cloud. This is the capstone project for Topic 3. The idea is straightforward — you are going to create a public-facing website, hosted entirely on Salesforce, that showcases your skills, projects, certifications, and contact information. Every component on the site will be a custom Lightning Web Component that you build from scratch. By the end of this post you will have a fully deployable portfolio site that demonstrates everything you have learned.
After this post, Topic 4 begins, where we shift gears into Salesforce Architecture. But first, let us build something worth showing off.
Planning the Site
Before writing a single line of code, we need to plan what the portfolio site will look like and what data it will need. Good planning is the difference between a project that comes together smoothly and one that turns into a mess of rewrites.
Pages
We will keep the site simple and focused. The portfolio will have a single-page layout with multiple sections, similar to how many modern developer portfolios work. The sections will be:
- Hero Banner — A large introductory section with your name, title, and a short tagline.
- Skills Section — A grid showing the Salesforce technologies and tools you are proficient in.
- Project Showcase — A card-based layout displaying projects you have built, with descriptions and links.
- Contact Form — A form where visitors can send you a message directly from the site.
Data Model
We need a place to store the data that populates these sections. We will create two custom objects:
- Portfolio_Skill__c — Stores skills with fields for
Name,Category__c(picklist: Salesforce, LWC, Apex, Integration, etc.), andProficiency__c(number 1-100). - Portfolio_Project__c — Stores projects with fields for
Name,Description__c(long text area),Technologies__c(text),Project_URL__c(URL), andImage_URL__c(URL).
For the contact form, we will create a Portfolio_Contact__c object with fields for Name__c, Email__c, Message__c (long text area), and Submitted_Date__c (date/time).
You can create these objects through Setup or include them in your SFDX project metadata. If you have been following along since the Salesforce configuration posts in Topic 1 and the data modeling discussions in Topic 2, this should feel very familiar.
Component Architecture
Each section of the portfolio maps to its own LWC:
portfolioHeroBannerportfolioSkillsportfolioProjectsportfolioContactForm
We will also create a parent wrapper component called portfolioApp that composes all four sections together. This is the composition pattern we covered back in Part 74 when we discussed templates and component composition. That parent component will be the one we actually drop into the Experience Cloud page.
Setting Up Experience Cloud
Before building the components, we need to set up the Experience Cloud site itself. If you have never enabled Digital Experiences in your org, here is how to do it.
Enabling Digital Experiences
- Go to Setup and search for “Digital Experiences” in the quick find box.
- Click Digital Experiences > Settings.
- Check the box to enable Digital Experiences.
- Choose a domain name. This will be something like
yourname-dev-ed.my.site.com. Salesforce will append the domain automatically. - Click Save.
Once enabled, you will see the option to create new sites under Digital Experiences > All Sites.
Creating the Site
- Click New to create a new site.
- Choose the Build Your Own (LWR) template. LWR stands for Lightning Web Runtime, and it is the modern framework that powers Experience Cloud sites. It is specifically designed to work well with Lightning Web Components, which is exactly what we need.
- Give the site a name like “My Portfolio” and a URL path like
/portfolio. - Click Create.
Salesforce will generate the site and drop you into Experience Builder. This is the drag-and-drop editor where you can add components to pages, configure the theme, and manage navigation. We will use Experience Builder to place our custom LWC components, but the real work happens in the code.
Exposing LWC Components to Experience Cloud
For a Lightning Web Component to appear in Experience Builder, it needs the correct targets in its metadata XML file. We covered design attributes and targeting back in Part 82. Here is the key target you need:
<targets>
<target>lightning__AppPage</target>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
The lightningCommunity__Page and lightningCommunity__Default targets are what make your component available in Experience Cloud. Without these, your component will not show up in the Experience Builder component palette.
Building the Custom LWC Components
Now we get to the core of the project. We are going to build each component one at a time, starting with the hero banner and working our way down the page.
The Portfolio App Wrapper
First, let us create the parent component that composes everything together. This is pure composition — no data fetching, no logic, just layout.
portfolioApp.html
<template>
<div class="portfolio-container">
<c-portfolio-hero-banner></c-portfolio-hero-banner>
<c-portfolio-skills></c-portfolio-skills>
<c-portfolio-projects></c-portfolio-projects>
<c-portfolio-contact-form></c-portfolio-contact-form>
</div>
</template>
portfolioApp.js
import { LightningElement } from 'lwc';
export default class PortfolioApp extends LightningElement {}
portfolioApp.css
.portfolio-container {
max-width: 1200px;
margin: 0 auto;
font-family: 'Salesforce Sans', Arial, sans-serif;
}
portfolioApp.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>59.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
</LightningComponentBundle>
This wrapper is the only component we will drag into Experience Builder. Everything else is nested inside it. If you recall from Part 74, this is exactly how component composition works — the parent references child components using the c- prefix and the kebab-case naming convention.
The Hero Banner Component
The hero banner is the first thing visitors see. It should be bold, clean, and immediately communicate who you are.
portfolioHeroBanner.html
<template>
<section class="hero-section">
<div class="hero-content">
<h1 class="hero-title">{name}</h1>
<p class="hero-subtitle">{title}</p>
<p class="hero-tagline">{tagline}</p>
<div class="hero-cta">
<lightning-button
label="View My Work"
variant="brand"
onclick={scrollToProjects}>
</lightning-button>
<lightning-button
label="Get in Touch"
variant="neutral"
onclick={scrollToContact}>
</lightning-button>
</div>
</div>
</section>
</template>
portfolioHeroBanner.js
import { LightningElement } from 'lwc';
export default class PortfolioHeroBanner extends LightningElement {
name = 'Your Name';
title = 'Salesforce Developer';
tagline = 'Building solutions with Apex, LWC, and the Salesforce Platform.';
scrollToProjects() {
const projectsSection = this.template.querySelector('.projects-section');
if (projectsSection) {
projectsSection.scrollIntoView({ behavior: 'smooth' });
}
this.dispatchEvent(new CustomEvent('scrollto', { detail: 'projects' }));
}
scrollToContact() {
this.dispatchEvent(new CustomEvent('scrollto', { detail: 'contact' }));
}
}
portfolioHeroBanner.css
.hero-section {
background: linear-gradient(135deg, #032d60 0%, #0176d3 100%);
color: white;
padding: 80px 40px;
text-align: center;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 8px;
}
.hero-subtitle {
font-size: 1.5rem;
font-weight: 300;
margin-bottom: 12px;
opacity: 0.9;
}
.hero-tagline {
font-size: 1.1rem;
margin-bottom: 32px;
opacity: 0.8;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.hero-cta {
display: flex;
gap: 16px;
justify-content: center;
}
Notice the dispatchEvent call in the scroll methods. We covered custom events extensively in Part 75. Here we are dispatching a scrollto event that the parent component can listen for to handle cross-component scrolling. The parent wrapper would need an event handler to find the right section and scroll to it. This is a practical example of child-to-parent communication that we discussed in the events post.
The Skills Section Component
The skills section pulls data from the Portfolio_Skill__c custom object and renders it in a grid. This is where the Apex integration we learned in Parts 78 and 79 comes into play.
First, the Apex controller:
PortfolioController.cls
public with sharing class PortfolioController {
@AuraEnabled(cacheable=true)
public static List<Portfolio_Skill__c> getSkills() {
return [
SELECT Id, Name, Category__c, Proficiency__c
FROM Portfolio_Skill__c
ORDER BY Category__c, Name
];
}
@AuraEnabled(cacheable=true)
public static List<Portfolio_Project__c> getProjects() {
return [
SELECT Id, Name, Description__c, Technologies__c,
Project_URL__c, Image_URL__c
FROM Portfolio_Project__c
ORDER BY CreatedDate DESC
];
}
@AuraEnabled
public static void submitContact(String name, String email, String message) {
Portfolio_Contact__c contact = new Portfolio_Contact__c(
Name__c = name,
Email__c = email,
Message__c = message,
Submitted_Date__c = System.now()
);
insert contact;
}
}
The cacheable=true annotation on the first two methods means they can be called with the @wire decorator. The submitContact method is not cacheable because it performs a DML operation, so it will be called imperatively. This distinction between wired and imperative Apex calls is something we covered in detail in Part 79.
Now the component:
portfolioSkills.html
<template>
<section class="skills-section">
<h2 class="section-title">Skills and Expertise</h2>
<p class="section-subtitle">Technologies I work with on the Salesforce Platform</p>
<template if:true={skills.data}>
<div class="skills-grid">
<template for:each={groupedSkills} for:item="group">
<div key={group.category} class="skill-category">
<h3 class="category-title">{group.category}</h3>
<template for:each={group.items} for:item="skill">
<div key={skill.Id} class="skill-item">
<span class="skill-name">{skill.Name}</span>
<div class="skill-bar-container">
<div class="skill-bar" style={skill.barStyle}></div>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
<template if:true={skills.error}>
<p class="error-text">Unable to load skills. Please try again later.</p>
</template>
</section>
</template>
portfolioSkills.js
import { LightningElement, wire } from 'lwc';
import getSkills from '@salesforce/apex/PortfolioController.getSkills';
export default class PortfolioSkills extends LightningElement {
@wire(getSkills)
skills;
get groupedSkills() {
if (!this.skills.data) return [];
const groups = {};
this.skills.data.forEach(skill => {
const category = skill.Category__c || 'General';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push({
...skill,
barStyle: `width: ${skill.Proficiency__c}%`
});
});
return Object.keys(groups).map(category => ({
category,
items: groups[category]
}));
}
}
portfolioSkills.css
.skills-section {
padding: 60px 40px;
background-color: #f4f6f9;
}
.section-title {
font-size: 2rem;
text-align: center;
color: #032d60;
margin-bottom: 8px;
}
.section-subtitle {
text-align: center;
color: #706e6b;
margin-bottom: 40px;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 32px;
}
.skill-category {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.category-title {
font-size: 1.1rem;
color: #0176d3;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e5e5;
}
.skill-item {
margin-bottom: 12px;
}
.skill-name {
font-size: 0.9rem;
color: #3e3e3c;
display: block;
margin-bottom: 4px;
}
.skill-bar-container {
background: #e5e5e5;
border-radius: 4px;
height: 8px;
overflow: hidden;
}
.skill-bar {
background: linear-gradient(90deg, #0176d3, #04a9f4);
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
}
A few things to note here. The @wire decorator automatically calls the Apex method and keeps the component reactive — when the data comes back, the template re-renders. The groupedSkills getter transforms the flat list of skills into a grouped structure that is easier to iterate over in the template. We are using computed styles for the progress bar width, passing the barStyle property directly into the style attribute. This is a pattern we touched on in Part 77 when we discussed dynamic styling in LWC.
The Project Showcase Component
The project showcase is the centerpiece of the portfolio. It displays your Salesforce projects in a card layout, each with a description, technology tags, and a link to the project.
portfolioProjects.html
<template>
<section class="projects-section">
<h2 class="section-title">Projects</h2>
<p class="section-subtitle">Salesforce solutions I have built</p>
<template if:true={projects.data}>
<div class="projects-grid">
<template for:each={projects.data} for:item="project">
<div key={project.Id} class="project-card">
<template if:true={project.Image_URL__c}>
<div class="project-image">
<img src={project.Image_URL__c} alt={project.Name} />
</div>
</template>
<div class="project-info">
<h3 class="project-name">{project.Name}</h3>
<p class="project-description">{project.Description__c}</p>
<p class="project-tech">{project.Technologies__c}</p>
<template if:true={project.Project_URL__c}>
<a href={project.Project_URL__c}
target="_blank"
class="project-link">
View Project
</a>
</template>
</div>
</div>
</template>
</div>
</template>
<template if:true={projects.error}>
<p class="error-text">Unable to load projects.</p>
</template>
</section>
</template>
portfolioProjects.js
import { LightningElement, wire } from 'lwc';
import getProjects from '@salesforce/apex/PortfolioController.getProjects';
export default class PortfolioProjects extends LightningElement {
@wire(getProjects)
projects;
}
portfolioProjects.css
.projects-section {
padding: 60px 40px;
background-color: white;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 24px;
}
.project-card {
border: 1px solid #e5e5e5;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.project-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.project-image img {
width: 100%;
height: 200px;
object-fit: cover;
}
.project-info {
padding: 20px;
}
.project-name {
font-size: 1.2rem;
color: #032d60;
margin-bottom: 8px;
}
.project-description {
font-size: 0.95rem;
color: #3e3e3c;
margin-bottom: 12px;
line-height: 1.5;
}
.project-tech {
font-size: 0.85rem;
color: #0176d3;
margin-bottom: 16px;
font-style: italic;
}
.project-link {
display: inline-block;
color: #0176d3;
font-weight: 600;
text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color 0.2s ease;
}
.project-link:hover {
border-bottom-color: #0176d3;
}
This component is intentionally simple. The @wire decorator handles all the data fetching, and the template handles all the rendering. There is no imperative logic needed. This is one of the strengths of the wire service — for read-only data display, you can build an entire component with almost no JavaScript. We discussed this exact advantage back in Part 79 when comparing wired versus imperative Apex calls.
The Contact Form Component
The contact form is the most interactive component on the site. It collects visitor input and saves it to Salesforce by calling an imperative Apex method. This ties together form handling, input validation, Apex integration, and user feedback — all concepts we have covered throughout this series.
portfolioContactForm.html
<template>
<section class="contact-section">
<h2 class="section-title">Get in Touch</h2>
<p class="section-subtitle">Have a question or want to work together?</p>
<div class="form-container">
<template if:false={isSubmitted}>
<lightning-input
label="Name"
value={contactName}
onchange={handleNameChange}
required
class="form-field">
</lightning-input>
<lightning-input
label="Email"
type="email"
value={contactEmail}
onchange={handleEmailChange}
required
class="form-field">
</lightning-input>
<lightning-textarea
label="Message"
value={contactMessage}
onchange={handleMessageChange}
required
max-length="2000"
class="form-field">
</lightning-textarea>
<div class="form-actions">
<lightning-button
label={buttonLabel}
variant="brand"
onclick={handleSubmit}
disabled={isSubmitting}>
</lightning-button>
</div>
<template if:true={errorMessage}>
<p class="error-text">{errorMessage}</p>
</template>
</template>
<template if:true={isSubmitted}>
<div class="success-message">
<lightning-icon
icon-name="utility:check"
size="large"
variant="success">
</lightning-icon>
<h3>Thank you for reaching out.</h3>
<p>Your message has been received. I will get back to you soon.</p>
<lightning-button
label="Send Another Message"
variant="neutral"
onclick={handleReset}>
</lightning-button>
</div>
</template>
</div>
</section>
</template>
portfolioContactForm.js
import { LightningElement } from 'lwc';
import submitContact from '@salesforce/apex/PortfolioController.submitContact';
export default class PortfolioContactForm extends LightningElement {
contactName = '';
contactEmail = '';
contactMessage = '';
isSubmitting = false;
isSubmitted = false;
errorMessage = '';
get buttonLabel() {
return this.isSubmitting ? 'Submitting...' : 'Send Message';
}
handleNameChange(event) {
this.contactName = event.target.value;
}
handleEmailChange(event) {
this.contactEmail = event.target.value;
}
handleMessageChange(event) {
this.contactMessage = event.target.value;
}
validateForm() {
const allValid = [
...this.template.querySelectorAll('lightning-input, lightning-textarea')
].reduce((validSoFar, inputField) => {
inputField.reportValidity();
return validSoFar && inputField.checkValidity();
}, true);
return allValid;
}
async handleSubmit() {
if (!this.validateForm()) {
return;
}
this.isSubmitting = true;
this.errorMessage = '';
try {
await submitContact({
name: this.contactName,
email: this.contactEmail,
message: this.contactMessage
});
this.isSubmitted = true;
} catch (error) {
this.errorMessage = error.body
? error.body.message
: 'Something went wrong. Please try again.';
} finally {
this.isSubmitting = false;
}
}
handleReset() {
this.contactName = '';
this.contactEmail = '';
this.contactMessage = '';
this.isSubmitted = false;
this.errorMessage = '';
}
}
portfolioContactForm.css
.contact-section {
padding: 60px 40px;
background-color: #f4f6f9;
}
.form-container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 32px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.form-field {
margin-bottom: 16px;
}
.form-actions {
margin-top: 24px;
text-align: center;
}
.error-text {
color: #c23934;
text-align: center;
margin-top: 16px;
font-size: 0.9rem;
}
.success-message {
text-align: center;
padding: 40px 20px;
}
.success-message h3 {
margin-top: 16px;
font-size: 1.4rem;
color: #2e844a;
}
.success-message p {
color: #706e6b;
margin-top: 8px;
margin-bottom: 24px;
}
There is a lot going on in this component, but none of it should be unfamiliar. The handleSubmit method uses an async/await pattern to call the imperative Apex method — this is the same pattern we used in Part 79. The validateForm method uses reportValidity and checkValidity on the input elements to trigger built-in validation, which we discussed in the context of form handling. The error handling follows the patterns from Part 87 on exception handling, where we extract the error message from error.body.message. And the conditional rendering with if:true and if:false is something we have been doing since Part 74.
Styling with SLDS and Custom CSS
Throughout the components above, we used a mix of custom CSS and Salesforce Lightning Design System (SLDS) base components like lightning-button, lightning-input, lightning-textarea, and lightning-icon. This is intentional. SLDS components give you accessible, well-tested UI elements out of the box, while custom CSS lets you build a unique visual identity for the portfolio.
A few styling principles to keep in mind for Experience Cloud sites:
Shadow DOM boundaries still apply. Each LWC component has its own shadow DOM, which means styles do not leak between components. We covered this in detail in Part 77. If you want consistent typography across all sections, you will need to define those base styles in each component or use CSS custom properties (variables) passed from the parent.
Use CSS custom properties for theming. You can define variables in the parent component and consume them in child components. This is a clean way to maintain a consistent color palette:
/* In portfolioApp.css */
:host {
--portfolio-primary: #032d60;
--portfolio-accent: #0176d3;
--portfolio-text: #3e3e3c;
--portfolio-light-bg: #f4f6f9;
}
Then in child components:
.section-title {
color: var(--portfolio-primary);
}
CSS custom properties cross shadow DOM boundaries, making them the ideal tool for theming a multi-component layout like this one.
Responsive design matters. Experience Cloud sites will be accessed on phones, tablets, and desktops. The grid-template-columns: repeat(auto-fit, minmax(...)) pattern we used in the skills and projects grids handles this gracefully. The cards will automatically wrap to fewer columns on smaller screens without any media queries.
Deploying and Sharing the Site
With all the components built, it is time to deploy and publish.
Deploying Components to the Org
If you are using Salesforce CLI (which we covered in the developer tooling posts), deploy your components with:
sf project deploy start --source-dir force-app/main/default/lwc
Make sure to also deploy the Apex controller:
sf project deploy start --source-dir force-app/main/default/classes
And the custom objects and fields:
sf project deploy start --source-dir force-app/main/default/objects
Or deploy everything at once:
sf project deploy start --source-dir force-app
Adding Components to Experience Builder
Once your components are deployed:
- Open the Experience Builder for your portfolio site (Setup > Digital Experiences > All Sites > Builder).
- Navigate to your home page.
- From the component palette on the left, find your portfolioApp component under the Custom Components section.
- Drag it onto the page.
- The wrapper component will render all four sections — hero, skills, projects, and contact form.
Setting Guest User Access
For a public portfolio site, you need to ensure that unauthenticated visitors can access the data. This means:
- Guest User Profile — Go to the site settings in Experience Builder, find the guest user profile, and grant read access to
Portfolio_Skill__candPortfolio_Project__c. Grant create access toPortfolio_Contact__cso the contact form can save submissions. - Apex Class Access — Add the
PortfolioControllerclass to the guest user profile’s Apex Class Access list. Without this, the guest user will not be able to execute the Apex methods, and your components will fail silently. - Object Permissions — Make sure the field-level security on all the fields we are querying is set to visible for the guest user profile.
This is one of the most common gotchas with Experience Cloud development. Your components might work perfectly when you are logged in as an admin, but return no data for guest users because of missing permissions.
Publishing the Site
Once everything looks right in Experience Builder:
- Click the Publish button in the top right corner.
- Confirm the publish action.
- Your site will be live at the URL you configured, something like
https://yourname-dev-ed.my.site.com/portfolio.
Share that link on your LinkedIn, resume, or anywhere you want to showcase your Salesforce skills. You now have a live, Salesforce-hosted portfolio that demonstrates real LWC development.
Section Notes
This post wraps up Topic 3: Lightning Web Components. Over the course of twenty-five sections, we went from the absolute basics of HTML, CSS, JavaScript, and the DOM all the way through to building a complete, deployable Experience Cloud site with custom Lightning Web Components. That is a serious journey, and if you have followed along with every post, you now have a solid working knowledge of LWC development.
Here is a quick recap of what we pulled together in this capstone project:
- Component composition (Part 74) — Building a parent wrapper that composes child components.
- Events (Part 75) — Dispatching custom events for cross-component communication.
- Lifecycle hooks and decorators (Part 76) — Using
@wirefor reactive data binding. - Styling (Part 77) — Custom CSS, SLDS integration, and CSS custom properties for theming.
- Apex integration (Parts 78-79) — Both wired and imperative Apex calls for data retrieval and DML.
- Design attributes and targeting (Part 82) — Exposing components to Experience Cloud via XML metadata.
- Lightning Data Service patterns (Part 83) — Understanding when to use LDS versus custom Apex.
- Exception handling (Part 87) — Graceful error handling in the contact form.
- Navigation (Part 88) — Understanding how navigation works in the context of Experience Cloud.
Every one of those earlier posts contributed a concept that made this project possible. That is the whole point of a capstone — it is not about learning something new, it is about demonstrating that you can combine everything you already know into something real and functional.
In the next post, we begin Topic 4: Salesforce Architecture. We will shift from building individual components to thinking about how large Salesforce implementations are designed, governed, and scaled. The perspective changes from developer to architect, and the questions change from “how do I build this?” to “how should this be built?”
See you there.