How do you make FullStack Demo App for Interviews using Laravel + React with Dockerize Part 4



This content originally appeared on DEV Community and was authored by pirvanm

How to Build a Full-Stack Demo App for Interviews with Laravel + React (Dockerized) – Part 1

Building a Full-Stack Demo App for Interviews (Laravel + React, Dockerized) – Part 2

How to Build a Full-Stack Demo App for Interviews with Laravel + React (Dockerized) – Part 3

This is the second main Part B, and Part 4 of the full series. You can access the previous parts above. I think I’m speaking more like someone recording a personal journal — maybe with a bit too much imagination.

Let’s start coding! Just type this command in the terminal. (Of course, this is a Linux terminal — for Windows users, you can use Cmder or Laragon, by the way.)npm create vite@latest my-app

Then I selected the React framework for the lightweight version, and chose TypeScript — because it seems TypeScript is loved even more than Go now.

After pressing Enter in the terminal a few times, I ended up with this familiar JavaScript package manager file: package.json.

{
  "name": "my-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.1.1",
    "react-dom": "^19.1.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.33.0",
    "@types/react": "^19.1.10",
    "@types/react-dom": "^19.1.7",
    "@vitejs/plugin-react": "^5.0.0",
    "eslint": "^9.33.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
    "globals": "^16.3.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.39.1",
    "vite": "^7.1.2"
  }
}

Corrected:
In my package.json, at the end I have:

First, checking one by one:

In “scripts”:

Next, in “dependencies”, I added:

“@tanstack/react-query”: “^5.84.1”,
“axios”: “^1.11.0”,
“react-router”: “^7.7.1”

And in the last step for packages, I just added one line in “scripts”:

“test”: “vitest”

In “devDependencies”, I added:

“vitest”: “^3.2.4”

After that, I think all the magic happens in frontend/src/, which is in the same folder as the previous backend folder.

In frontend/src
/.env
I get it, but I think this is more logical in Docker, which I’ll talk about later — maybe in the next part.

frontend/src
/main.tsx before i start overwrite this i have :

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

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

After some work, some ambition — maybe too much work…

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { BrowserRouter } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </BrowserRouter>,
  </StrictMode>,
)

Maybe it’s because I’m old — but not that old — I’m still a fan of ESLint config. So I made this one for formatting, in the eslint.config.js file:

import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'

export default tseslint.config([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs['recommended-latest'],
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
  },
])

Some basic HTML for the main page: index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

After that, to run tests properly, I created a file setupTests.ts with the following code:

import ‘@testing-library/jest-dom’;

As the next sub-stage of Stage B, I made a pages folder inside src, like this:

src
└── pages/

Inside it i have AUth, Home, MentorProfile
In Auth , more precise on :
frontend/src/pages/Auth
/Login.tsx

const Login: React.FC = () => {
  return (
    <div>

    </div>
  );
}

export default Login;

On register :

const Register: React.FC = () => {
  return (
    <div>

    </div>
  );
}

export default Register;

One step closer to finishing this part, we go to Home.tsx

Path: frontend/src/pages/Home/Home.tsx
And the following code:

import MentorList from "../../features/mentors/MentorsList";

const Home: React.FC = () => {
  return (
    <div>
        <header className="page-header">
            <h1>Find a Mentor</h1>
            <p>Get guidance from experienced developers, designers, and leaders.</p>
        </header>
        <MentorList />
    </div>
  );
}

export default Home;

And on the latest step of this part 4 of series, we made a new folder

src/pages
/MentorProfile/

Some CSS for beginners in web development — boring or seemingly useless stuff, but still necessary:

.back-link {
    display: inline-block;
    margin-bottom: 1.5rem;
    color: #475569;
    text-decoration: none;
    font-size: 0.95rem;
}

.back-link:hover {
    color: #1e293b;
}

.mentor-header {
    display: flex;
    gap: 1.5rem;
    align-items: flex-start;
    margin-bottom: 1.5rem;
}

.mentor-avatar {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    object-fit: cover;
}

.mentor-info {
    flex: 1;
}

.mentor-name {
    font-size: 1.8rem;
    font-weight: 600;
    color: #1e293b;
}

.mentor-title {
    font-size: 1rem;
    color: #475569;
    margin: 0.25rem 0;
}

.mentor-expertise {
    font-size: 0.95rem;
    color: #64748b;
}

.mentor-bio {
    line-height: 1.7;
    color: #334155;
    margin-bottom: 2rem;
}

.mentor-bio h2 {
    font-size: 1.3rem;
    margin: 1.5rem 0 1rem;
    color: #1e293b;
}

.mentor-bio p {
    margin-bottom: 1rem;
}

.mentor-requirements {
    background-color: #f8fafc;
    border: 1px solid #e2e8f0;
    border-radius: 0.5rem;
    padding: 1.25rem;
    margin-bottom: 2rem;
    font-size: 0.95rem;
    color: #475569;
}

.mentor-requirements h3 {
    font-size: 1.1rem;
    color: #1e293b;
    margin-bottom: 0.75rem;
}

.apply-section {
    text-align: center;
    margin-top: 1rem;
}

.btn-apply {
    display: inline-block;
    background-color: #4f46e5;
    color: white;
    padding: 0.75rem 2rem;
    font-size: 1rem;
    font-weight: 500;
    text-decoration: none;
    border-radius: 0.5rem;
    transition: background-color 0.2s;
}

.btn-apply:hover {
    background-color: #4338ca;
}

@media (max-width: 640px) {
    .mentor-header {
        flex-direction: column;
        align-items: center;
        text-align: center;
    }
    .mentor-name {
        font-size: 1.6rem;
    }
}

/MentorProfile.module.css

In the next file: frontend/src/pages/MentorProfile/MentorProfile.test.tsx

i got

import { render, screen } from '@testing-library/react';
import MentorProfile from './MentorProfile';
import { useMentor } from '../../api/queries/mentors/useMentor';
import { MemoryRouter } from 'react-router';


vi.mock('../../api/queries/mentors/useMentor');

describe('MentorProfile', () => {
  test('shows skeleton when loading', () => {
    (useMentor as jest.Mock).mockReturnValue({
      data: null,
      isLoading: true,
      error: null,
    });

    render(<MentorProfile />);

    expect(screen.getByTestId('skeleton-avatar')).toBeInTheDocument();
  });

  test('shows error component when error occurs', () => {
    (useMentor as jest.Mock).mockReturnValue({
      data: null,
      isLoading: false,
      error: new Error('Failed to fetch'),
    });

    render(<MemoryRouter>
        <MentorProfile />
    </MemoryRouter>);

    expect(screen.getByText('Whoops!')).toBeInTheDocument();
  });

  test('renders full profile when data is loaded', () => {
    (useMentor as jest.Mock).mockReturnValue({
      data: {
        id: 1,
        fullName: 'Sarah Chen',
        title: 'Senior Frontend Engineer',
        expertise: ['React', 'TypeScript'],
        bio: 'I love mentoring.',
        technicalBio: 'I love mentoring.',
        mentoringStyle: 'I love mentoring.',
        audience: 'I love mentoring.',
        availability: 'open',
      },
      isLoading: false,
      error: null,
    });

    render(<MemoryRouter>
        <MentorProfile />
    </MemoryRouter>);

    expect(screen.getByText('Sarah Chen')).toBeInTheDocument();
    expect(screen.getByText('Senior Frontend Engineer')).toBeInTheDocument();
    expect(screen.getByText('REACT • TYPESCRIPT')).toBeInTheDocument();
    expect(screen.getByText('Apply to Be My Mentee')).toBeInTheDocument();
  });
});

On MentorProfile.tsx

import { Link, useParams } from 'react-router';
import styles from './MentorProfile.module.css'
import { useMentor } from '../../api/queries/mentors/useMentor';
import SkeletonMentorProfile from './SkeletonMentorProfile';
import Error from '../../shared/ui/Error/Error';

const MentorProfile: React.FC = () => {
  const { id }  = useParams<{id: string}>();

  const { data: mentor, isLoading, error } = useMentor(parseInt(id ?? '0'));

  if (isLoading) {
    return (
      <SkeletonMentorProfile />
    )
  }

  if (error) return <Error />;

  return (
    <main className={styles['mentor-detail']}>
      <Link to={'/'} className={styles['back-link']}>&larr; Back to mentors</Link>


      <div className={styles['mentor-header']}>
        <img src={mentor?.avatar ?? 'https://randomuser.me/api/portraits/women/45.jpg'} alt={mentor?.fullName} className={styles['mentor-avatar']} />
        <div className={styles['mentor-info']}>
          <h1 className={styles['mentor-name']}>{mentor?.fullName}</h1>
          <div className={styles['mentor-title']}>{mentor?.title}</div>
          <div className={styles['mentor-expertise']}>{mentor?.expertise.slice(0, 3).join(' • ').toUpperCase()}</div>
        </div>
      </div>

      <section className={styles['mentor-bio']}>
        <h2>About {mentor?.fullName}</h2>
        <p>
          {mentor?.bio}
        </p>

        <h2>What I Can Help With</h2>
        <p>
          {mentor?.technicalBio}
        </p>

        <h2>My Mentoring Style</h2>
        <p>
            {mentor?.mentoringStyle}
        </p>
      </section>

      <section className={styles['mentor-requirements']}>
        <h3>Who I'm Looking For</h3>
        <p>
          {mentor?.audience}
        </p>
      </section>

      <div className={styles['apply-section']}>
        <Link to={`/mentor/${id}`} className={styles['btn-apply']}>
          Apply to Be My Mentee
        </Link>
      </div>
    </main>
  );
}

export default MentorProfile;

In frontend/src/pages/MentorProfile/SkeletonMentorProfile.module.css

.back-link {
  display: inline-block;
  width: 120px;
  height: 16px;
  background: #e2e8f0;
  border-radius: 0.375rem;
  margin-bottom: 1.5rem;
  animation: pulse 2s infinite ease-in-out;
}

.mentor-header {
  display: flex;
  gap: 1.5rem;
  align-items: flex-start;
  margin-bottom: 1.5rem;
}

.mentor-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: #e2e8f0;
  animation: pulse 2s infinite ease-in-out;
}

.mentor-info {
  flex: 1;
}

.mentor-name {
  width: 180px;
  height: 36px;
  background: #e2e8f0;
  border-radius: 0.375rem;
  margin: 0.25rem 0;
  animation: pulse 2s infinite ease-in-out;
  animation-delay: 0.2s;
}

.mentor-title {
  width: 200px;
  height: 18px;
  background: #f7fafc;
  border-radius: 0.375rem;
  margin: 0.25rem 0;
  animation: pulse 2s infinite ease-in-out;
  animation-delay: 0.4s;
}

.mentor-expertise {
  width: 160px;
  height: 16px;
  background: #f7fafc;
  border-radius: 0.375rem;
  margin-top: 0.5rem;
  animation: pulse 2s infinite ease-in-out;
  animation-delay: 0.6s;
}

.apply-section {
  text-align: center;
  margin-top: 1rem;
}

.btn-apply {
  display: inline-block;
  width: 200px;
  height: 48px;
  background: #e2e8f0;
  border-radius: 0.5rem;
  animation: pulse .3s infinite ease-in-out;
  animation-delay: 0.8s;
}

/* Pulse Animation */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

And the latest file for this part.

import styles from './SkeletonMentorProfile.module.css';

const SkeletonMentorProfile: React.FC = () => {
  return (
    <main className={styles['mentor-detail']}>
      <div className={styles['back-link']}></div>

      <div className={styles['mentor-header']}>
        <div className={styles['mentor-avatar']} data-testid="skeleton-avatar"/>
        <div className={styles['mentor-info']}>
          <div className={styles['mentor-name']} />
          <div className={styles['mentor-title']} />
          <div className={styles['mentor-expertise']} />
        </div>
      </div>

      <div className={styles['apply-section']}>
        <div className={styles['btn-apply']} />
      </div>
    </main>
  );
};

export default SkeletonMentorProfile;

I think this can be seen as a full-stack course for someone who wants to move from mid-level to senior. For more awesome courses, I invite you to check out my live React/Laravel project, hosted on DigitalOcean with GitHub Actions and Docker.
CourseCasts!


This content originally appeared on DEV Community and was authored by pirvanm