Build a Video Platform: Project Setup

Time to write code. In this post, we'll go from an empty directory to a running development stack — Spring Boot API, Next.js frontend, PostgreSQL, and Redis, all wired together with Docker Compose and hot reload on both backend and frontend.
No authentication yet. No video uploads. No Stripe. Just a solid foundation with the right project structure, profiles, and development workflow. We'll layer everything else on top in later posts.
Time commitment: 1–2 hours
Prerequisites: Series Overview & Architecture
What we'll build in this post:
✅ Spring Boot API project with layered package structure
✅ Next.js frontend with App Router, TypeScript, and Tailwind CSS
✅ Docker Compose for PostgreSQL and Redis
✅ Development and production Spring profiles
✅ Health check endpoint and CORS configuration
✅ Hot reload workflow for both backend and frontend
Repository Structure
Here's the monorepo layout we'll build. Backend and frontend live side by side:
video-platform/
├── backend/ # Spring Boot API
│ ├── src/main/java/dev/chanhle/courses/
│ │ ├── CoursesApplication.java # Main entry point
│ │ ├── config/
│ │ │ └── CorsConfig.java # CORS configuration
│ │ ├── health/
│ │ │ └── HealthController.java # Health check endpoint
│ │ └── common/
│ │ ├── dto/
│ │ │ └── ApiResponse.java # Standard API response wrapper
│ │ └── exception/
│ │ ├── GlobalExceptionHandler.java
│ │ └── ResourceNotFoundException.java
│ ├── src/main/resources/
│ │ ├── application.yml # Shared config
│ │ ├── application-dev.yml # Development profile
│ │ └── application-prod.yml # Production profile
│ ├── build.gradle # Gradle build config
│ ├── settings.gradle
│ └── Dockerfile
├── frontend/ # Next.js app
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Home page
│ │ └── globals.css # Global styles
│ ├── components/
│ │ └── Header.tsx # Navigation header
│ ├── lib/
│ │ └── api.ts # Axios instance
│ ├── next.config.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (later)
├── .env.example
├── .gitignore
└── README.mdWhy this structure:
- Monorepo — backend and frontend in one repository. Simpler to manage, version, and deploy together
config/— centralized configuration classes (CORS, security, Redis — we'll add more)common/— shared DTOs and exception handling, used across all domainshealth/— health check endpoint. Later domains (auth/,course/,video/,subscription/) follow the same pattern
Spring Boot Backend Setup
Generate the Project
Head to start.spring.io and create a project with these settings:
| Setting | Value |
|---|---|
| Project | Gradle - Groovy |
| Language | Java |
| Spring Boot | 3.4.x (latest stable) |
| Group | dev.chanhle |
| Artifact | courses |
| Name | courses |
| Package | dev.chanhle.courses |
| Java | 17 |
Dependencies to add:
- Spring Web
- Spring Data JPA
- PostgreSQL Driver
- Spring Data Redis
- Validation
- Lombok
- Spring Boot DevTools
Or use the command line:
mkdir video-platform && cd video-platform
curl https://start.spring.io/starter.zip \
-d type=gradle-project \
-d language=java \
-d bootVersion=3.4.4 \
-d groupId=dev.chanhle \
-d artifactId=courses \
-d name=courses \
-d packageName=dev.chanhle.courses \
-d javaVersion=17 \
-d dependencies=web,data-jpa,postgresql,data-redis,validation,lombok,devtools \
-o backend.zip
unzip backend.zip -d backend
rm backend.zipGradle Build Configuration
Open backend/build.gradle and verify the dependencies:
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.4'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'dev.chanhle'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}Why these dependencies:
- spring-boot-starter-web — REST controllers, Jackson JSON, embedded Tomcat
- spring-boot-starter-data-jpa — Hibernate ORM, repository pattern, transaction management
- spring-boot-starter-data-redis — Redis client for caching and session storage
- spring-boot-starter-validation — Bean validation (
@Valid,@NotBlank,@Email) - lombok — reduce boilerplate (
@Data,@Builder,@Slf4j) - devtools — automatic restart on code changes (hot reload)
Application Configuration
Spring Boot uses YAML profiles to separate configuration per environment. We'll create three files: shared, development, and production.
Shared Configuration
backend/src/main/resources/application.yml:
spring:
application:
name: video-courses-api
profiles:
active: dev
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
server:
port: 8080
error:
include-message: always
include-binding-errors: alwaysKey settings:
default-property-inclusion: non_null— omit null fields from JSON responses (cleaner API)write-dates-as-timestamps: false— serialize dates as ISO 8601 strings (2026-03-22T10:30:00Z)include-message: always— include error messages in responses (useful for debugging)
Development Profile
backend/src/main/resources/application-dev.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/courses_dev
username: courses
password: courses_dev_password
hikari:
maximum-pool-size: 5
jpa:
hibernate:
ddl-auto: validate
show-sql: true
properties:
hibernate:
format_sql: true
data:
redis:
host: localhost
port: 6379
devtools:
restart:
enabled: true
logging:
level:
dev.chanhle.courses: DEBUG
org.hibernate.SQL: DEBUGKey development settings:
ddl-auto: validate— validate schema against entities but don't modify it (Flyway handles migrations, which we'll add in Post #4)show-sql: true— print SQL queries to console for debuggingmaximum-pool-size: 5— small connection pool for local development- DevTools restart — Spring Boot restarts automatically when
.classfiles change
Production Profile
backend/src/main/resources/application-prod.yml:
spring:
datasource:
url: ${DATABASE_URL}
hikari:
maximum-pool-size: 20
minimum-idle: 5
jpa:
hibernate:
ddl-auto: validate
show-sql: false
data:
redis:
host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379}
devtools:
restart:
enabled: false
logging:
level:
dev.chanhle.courses: INFO
org.hibernate.SQL: WARN
server:
error:
include-message: never
include-stacktrace: neverKey production differences:
- Environment variables — sensitive values come from
${DATABASE_URL}, not hardcoded - Larger connection pool — 20 max connections for production load
- No SQL logging — avoid log noise
- Error messages hidden — don't leak internal details to clients
Standard API Response Wrapper
Every API endpoint should return a consistent response format. Create a generic wrapper:
backend/src/main/java/dev/chanhle/courses/common/dto/ApiResponse.java:
package dev.chanhle.courses.common.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private Object errors;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.build();
}
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.success(true)
.message(message)
.data(data)
.build();
}
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.build();
}
public static <T> ApiResponse<T> error(String message, Object errors) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.errors(errors)
.build();
}
}Usage in controllers:
// Success response
return ResponseEntity.ok(ApiResponse.success(courseDto));
// Success with message
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Course created", courseDto));
// Error response
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("Course not found"));Example JSON output:
{
"success": true,
"message": "Course created",
"data": {
"id": 1,
"title": "Spring Boot Fundamentals",
"slug": "spring-boot-fundamentals"
}
}Global Exception Handler
Catch all exceptions in one place and return consistent error responses:
backend/src/main/java/dev/chanhle/courses/common/exception/ResourceNotFoundException.java:
package dev.chanhle.courses.common.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, String field, Object value) {
super(String.format("%s not found with %s: %s", resource, field, value));
}
}backend/src/main/java/dev/chanhle/courses/common/exception/GlobalExceptionHandler.java:
package dev.chanhle.courses.common.exception;
import dev.chanhle.courses.common.dto.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Validation failed", errors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Validation failed", errors));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("An unexpected error occurred"));
}
}This gives us consistent error responses across the entire API:
{
"success": false,
"message": "Validation failed",
"errors": {
"email": "must not be blank",
"title": "size must be between 3 and 255"
}
}CORS Configuration
The frontend (Next.js on port 3000) needs to make API calls to Spring Boot (port 8080). Without CORS, browsers block these cross-origin requests.
backend/src/main/java/dev/chanhle/courses/config/CorsConfig.java:
package dev.chanhle.courses.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://localhost:3001"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}Key settings:
allowCredentials: true— needed for HTTP-only cookies (refresh tokens in Post #3)allowedOrigins— only our frontend can make requests. In production, this changes to the real domainexposedHeaders: Authorization— the frontend needs to read theAuthorizationheader from responses/api/**— CORS only applies to API routes, not static file serving
Health Check Endpoint
A simple endpoint to verify the API is running. Useful for Docker health checks, load balancers, and monitoring.
backend/src/main/java/dev/chanhle/courses/health/HealthController.java:
package dev.chanhle.courses.health;
import dev.chanhle.courses.common.dto.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class HealthController {
@GetMapping("/health")
public ResponseEntity<ApiResponse<Map<String, Object>>> health() {
Map<String, Object> healthData = Map.of(
"status", "UP",
"timestamp", Instant.now().toString(),
"service", "video-courses-api"
);
return ResponseEntity.ok(ApiResponse.success(healthData));
}
}Test it:
curl http://localhost:8080/api/health{
"success": true,
"data": {
"status": "UP",
"timestamp": "2026-03-22T10:30:00Z",
"service": "video-courses-api"
}
}Next.js Frontend Setup
Create the Project
cd video-platform
npx create-next-app@latest frontend \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir=false \
--import-alias="@/*" \
--turbopackThis creates a Next.js 15 project with:
- App Router — the modern routing system
- TypeScript — type safety
- Tailwind CSS — utility-first styling
- Turbopack — fast development builds
- No
src/directory — files directly underfrontend/
Install Additional Dependencies
cd frontend
npm install axios
npm install -D @types/node- axios — HTTP client with interceptor support (crucial for token refresh later)
API Client
Create a reusable Axios instance that all API calls will use:
frontend/lib/api.ts:
import axios from 'axios';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
// Response interceptor — extract data or handle errors
api.interceptors.response.use(
(response) => response.data,
(error) => {
const message = error.response?.data?.message || 'An unexpected error occurred';
return Promise.reject(new Error(message));
}
);
export default api;Key settings:
withCredentials: true— send cookies with every request (needed for refresh tokens)baseURL— all API calls are relative to this URL. In production, this points to the real API domain- Response interceptor — automatically unwraps the response and extracts the error message
We'll add a request interceptor in Post #3 to attach the JWT access token and handle automatic token refresh.
Environment Variables
frontend/.env.local:
NEXT_PUBLIC_API_URL=http://localhost:8080/apiThe NEXT_PUBLIC_ prefix makes the variable available in client-side code.
Root Layout
frontend/app/layout.tsx:
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Video Courses',
description: 'Learn programming with video courses',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.className} antialiased bg-gray-50 min-h-screen`}>
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</body>
</html>
);
}
function Header() {
return (
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<a href="/" className="text-xl font-bold text-gray-900">
Video Courses
</a>
<nav className="flex items-center space-x-6">
<a href="/courses" className="text-gray-600 hover:text-gray-900">
Courses
</a>
<a
href="/login"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
Sign In
</a>
</nav>
</div>
</div>
</header>
);
}Home Page
frontend/app/page.tsx:
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
interface HealthData {
success: boolean;
data: {
status: string;
timestamp: string;
service: string;
};
}
export default function Home() {
const [health, setHealth] = useState<HealthData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.get('/health')
.then((data) => setHealth(data as unknown as HealthData))
.catch((err) => setError(err.message));
}, []);
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Video Course Platform
</h1>
<p className="text-lg text-gray-600 mb-8">
Learn programming with high-quality video courses
</p>
<div className="max-w-md mx-auto bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">API Connection Status</h2>
{error && (
<div className="text-red-600 bg-red-50 p-3 rounded">
API Error: {error}
</div>
)}
{health && (
<div className="text-green-600 bg-green-50 p-3 rounded">
<p>Status: {health.data.status}</p>
<p className="text-sm text-gray-500 mt-1">
Service: {health.data.service}
</p>
</div>
)}
{!health && !error && (
<div className="text-gray-400">Connecting to API...</div>
)}
</div>
</div>
);
}This simple page connects to the Spring Boot health endpoint — a quick sanity check that the frontend-backend connection works.
Docker Compose
Here's the development Docker Compose that wires everything together:
docker-compose.yml:
services:
postgres:
image: postgres:17-alpine
container_name: courses-postgres
environment:
POSTGRES_DB: courses_dev
POSTGRES_USER: courses
POSTGRES_PASSWORD: courses_dev_password
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U courses -d courses_dev"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: courses-redis
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
redisdata:Important: We're only containerizing PostgreSQL and Redis — not the Spring Boot API or Next.js frontend. During development, running backend and frontend natively gives us:
- Faster hot reload — no Docker build step for code changes
- Easier debugging — attach IDE debugger directly
- Simpler logs — each process in its own terminal
In Post #15 (Deployment), we'll containerize everything for production.
Start the Services
docker compose up -dVerify they're running:
docker compose psNAME STATUS PORTS
courses-postgres Up (healthy) 0.0.0.0:5432->5432/tcp
courses-redis Up (healthy) 0.0.0.0:6379->6379/tcpTest database connection:
docker exec -it courses-postgres psql -U courses -d courses_dev -c "SELECT 1;"Test Redis connection:
docker exec -it courses-redis redis-cli ping
# PONGRunning the Development Stack
Now we have all the pieces. Here's the complete development workflow:
Step 1: Start Infrastructure
# From the project root
docker compose up -dStep 2: Run the Spring Boot API
cd backend
./gradlew bootRunYou should see:
Started CoursesApplication in 2.3 secondsTest the health endpoint:
curl http://localhost:8080/api/health | jq{
"success": true,
"data": {
"status": "UP",
"timestamp": "2026-03-22T10:30:00Z",
"service": "video-courses-api"
}
}Step 3: Run the Next.js Frontend
cd frontend
npm run devOpen http://localhost:3000 in your browser. You should see the home page with "Status: UP" — confirming the frontend is talking to the backend.
Development Architecture
Hot Reload Behavior
| Layer | Technology | What Happens on Save |
|---|---|---|
| Frontend | Next.js + Turbopack | Page refreshes instantly (~100ms) |
| Backend | Spring Boot DevTools | JVM restarts automatically (~2-3s) |
| Database | PostgreSQL (Docker) | Persistent — survives restarts |
| Cache | Redis (Docker) | Persistent — data kept in Docker volume |
Spring Boot DevTools watches for .class file changes. When you save a Java file, your IDE compiles it, DevTools detects the new class, and restarts the application context. The restart uses a "base classloader" trick that skips reloading third-party libraries, keeping restart times under 3 seconds.
Environment Variables
Create .env.example at the project root (committed to git):
# PostgreSQL
POSTGRES_DB=courses_dev
POSTGRES_USER=courses
POSTGRES_PASSWORD=courses_dev_password
DATABASE_URL=jdbc:postgresql://localhost:5432/courses_dev
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# Application
SPRING_PROFILES_ACTIVE=dev
NEXT_PUBLIC_API_URL=http://localhost:8080/apiCreate .gitignore at the project root:
# Dependencies
backend/build/
frontend/node_modules/
frontend/.next/
# Environment
.env
.env.local
backend/src/main/resources/application-local.yml
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db
# Docker
pgdata/
redisdata/Testing the Full Stack
Let's verify everything works end-to-end:
1. Health Check via curl
curl -s http://localhost:8080/api/health | jqExpected output:
{
"success": true,
"data": {
"status": "UP",
"timestamp": "2026-03-22T10:30:00Z",
"service": "video-courses-api"
}
}2. Frontend Connects to Backend
Open http://localhost:3000 — the home page should show "Status: UP" in a green box.
3. Database Connection
docker exec -it courses-postgres psql -U courses -d courses_dev -c "\dt"This should return "Did not find any relations" — no tables yet, which is correct. We'll create tables with Flyway migrations in Post #4.
4. Redis Connection
docker exec -it courses-redis redis-cli ping
# PONG
docker exec -it courses-redis redis-cli info server | head -5Complete Request Flow
Common Mistakes
1. PostgreSQL Port Conflict
If port 5432 is already in use (local PostgreSQL running):
# Check what's using port 5432
lsof -i :5432
# Option 1: Stop local PostgreSQL
brew services stop postgresql
# Option 2: Use a different port in docker-compose.yml
ports:
- "5433:5432" # Map to 5433 on hostDon't forget to update application-dev.yml to match the new port.
2. Spring Boot Can't Connect to PostgreSQL
If you see Connection refused errors:
# Check if PostgreSQL container is running
docker compose ps
# Check container logs
docker compose logs postgres
# Verify the container is healthy
docker inspect courses-postgres --format='{{.State.Health.Status}}'Make sure Docker Compose started before Spring Boot.
3. CORS Errors in Browser Console
If the frontend can't reach the API:
Access to XMLHttpRequest at 'http://localhost:8080/api/health' from origin
'http://localhost:3000' has been blocked by CORS policyCheck that CorsConfig.java includes http://localhost:3000 in allowedOrigins. Also verify withCredentials: true matches allowCredentials: true.
4. Lombok Not Working
If you see "cannot find symbol" errors for @Data or @Builder:
- IntelliJ: Install the Lombok plugin (Settings → Plugins → search "Lombok")
- VS Code: The Java extension pack usually handles it
- Make sure
annotationProcessor 'org.projectlombok:lombok'is inbuild.gradle
What We've Built
Let's recap what we have after this post:
What's Next?
Our platform runs, but there's no authentication — anyone can access any endpoint. In Post #3, we'll add the complete auth system:
- Spring Security configuration for JWT
- Access token + refresh token pattern
- Email/password registration and login
- Google and GitHub OAuth2 social login
- Next.js auth context and protected routes
- Token refresh interceptor with Axios
The foundation is solid. Time to add security.
Series: Build a Video Streaming Platform
Previous: Series Overview & Architecture
Next: Phase 2: Authentication
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.