05 - API Integration
Overview
Wavic WebApp communicates with the NestJS backend API using Next.js Server Actions. This pattern provides type-safe, server-side data fetching with automatic request deduplication.
Note: Entity endpoints (artist, project, track) accept any identifier type — ObjectId, shortId, or slug. The backend resolves them automatically via
findByIdentifier(). See API-DESIGN.md.
API Configuration
Environment Variables
bash
# src/.env.local
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 # Local development
# Production: set via GitHub Actions / Azure Static Web Apps configAPI Base URL
All API calls use the environment variable:
typescript
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/endpoint`,
{
/* options */
}
);Server Actions Architecture
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ Client │ │ Server Action │ │ API │
│ Component │────►│ ('use server') │────►│ (NestJS) │
│ │ │ │ │ :8080 │
└──────────────┘ └────────────────────┘ └──────────────┘Server Action Files
src/server-actions/
├── artist/
│ └── actions.tsx # Artist CRUD
├── project/
│ └── actions.tsx # Project CRUD
├── track/
│ └── actions.tsx # Track CRUD
├── subscription/
│ └── actions.tsx # Billing/subscription
├── shareWithUser.ts # Sharing functionality
└── zip-folders.ts # Download as ZIPAPI Endpoints
Artist Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /artist | List user's artists |
| GET | /artist/:id | Get artist details |
| POST | /artist | Create artist |
| PUT | /artist/:id | Update artist |
| DELETE | /artist/:id | Delete artist |
| GET | /project/artist | Artists from project collaborations |
| GET | /track/artist | Artists from track collaborations |
Project Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /project/:id | Get project details |
| POST | /project | Create project |
| PUT | /project/:id | Update project |
| DELETE | /project/:id | Delete project |
Track Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /track/:id | Get track details |
| GET | /track/project/:projectId | List project tracks |
| POST | /track | Create track |
| PUT | /track/:id | Update track |
| DELETE | /track/:id | Delete track |
| POST | /track/:id/file | Upload audio version |
| POST | /track/:id/attachments | Upload attachments |
User/Auth Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /user/signin | Email/password login |
| POST | /user/signup | Register new user |
| GET | /user/auth/google/callback | Google OAuth callback |
| GET | /user/profile | Get user profile |
| PUT | /user/profile | Update profile |
Other Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /link/:id | Get shared link details |
| GET | /search | Search artists/projects/tracks |
| GET | /trash | List deleted items |
| POST | /trash/restore/:id | Restore deleted item |
Server Action Examples
Fetching Artists
typescript
// src/server-actions/artist/actions.tsx
'use server';
import { TArtist } from 'types/artist';
export const getArtists = async (session: any) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/artist`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${session?.token}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json: TArtist[] = await response.json();
// Add cache-busting to images
const updatedArtists = json.map((artist) => ({
...artist,
image: artist.image
? `${artist.image}?timestamp=${new Date().getTime()}`
: '',
}));
return updatedArtists;
} catch (e) {
console.error(e as Error);
return [];
}
};Creating/Updating Artist
typescript
export const createOrUpdateArtist = async (
formData: FormData,
token: string | undefined,
artistId?: string
): Promise<TArtist> => {
const method = artistId ? 'PUT' : 'POST';
const url = artistId
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/artist/${artistId}`
: `${process.env.NEXT_PUBLIC_API_BASE_URL}/artist`;
try {
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
// Note: No Content-Type for FormData
},
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return (await response.json()) as TArtist;
} catch (error) {
throw new Error(
`Failed to ${artistId ? 'update' : 'create'} artist: ${error}`
);
}
};Uploading Track with Progress
typescript
// src/server-actions/track/actions.tsx
export const createTrackSpace = async ({
trackName,
token,
artistId,
projectId,
file,
setProgress,
}: {
trackName: string;
token: string | undefined;
artistId: string;
projectId: string;
file?: File;
setProgress: (percentage: number) => void;
}): Promise<TTrack> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('name', trackName);
formData.append('artistSpace', artistId);
formData.append('project', projectId);
if (file) formData.append('audio', file, file.name);
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setProgress(Math.floor(percentComplete));
}
});
xhr.open('POST', `${process.env.NEXT_PUBLIC_API_BASE_URL}/track`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as TTrack);
} else {
reject(new Error(`Upload failed: ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Upload error: Network issue'));
xhr.send(formData);
});
};Fetching Aggregated Data
typescript
export const fetchArtistsData = async (
token: string | undefined
): Promise<TArtist[]> => {
try {
// Parallel fetch from multiple endpoints
const endpoints = [
`${process.env.NEXT_PUBLIC_API_BASE_URL}/artist`,
`${process.env.NEXT_PUBLIC_API_BASE_URL}/project/artist`,
`${process.env.NEXT_PUBLIC_API_BASE_URL}/track/artist`,
];
const responses = await Promise.all(
endpoints.map((endpoint) =>
fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
)
);
const artistsData = await Promise.all(
responses.map((res) => res.json() as Promise<TArtist[]>)
);
// Flatten and deduplicate
return artistsData.flat();
} catch (e) {
console.error('Error fetching artists:', e);
return [];
}
};Using Server Actions in Components
Basic Usage
typescript
'use client';
import { useSession } from 'next-auth/react';
import { useEffect, useState } from 'react';
import { getArtists } from '@/server-actions/artist/actions';
export default function ArtistList() {
const { data: session } = useSession();
const [artists, setArtists] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
if (session?.token) {
const data = await getArtists(session);
setArtists(data);
setLoading(false);
}
}
fetchData();
}, [session]);
if (loading) return <Skeleton />;
return (
<div>
{artists.map((artist) => (
<ArtistCard key={artist._id} artist={artist} />
))}
</div>
);
}Direct Fetch in Page Components
For simpler cases, fetch directly in the component:
typescript
'use client';
export default function ProjectPage({ projectId }) {
const { data: session } = useSession();
const [project, setProject] = useState(null);
const fetchProjectData = async () => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/project/${projectId}`,
{
headers: {
Authorization: `Bearer ${session?.token}`,
'Content-Type': 'application/json',
},
}
);
return response.json();
};
useEffect(() => {
fetchProjectData().then(setProject);
}, [projectId, session]);
// ...
}Authentication Headers
All authenticated requests require the JWT token:
typescript
headers: {
Authorization: `Bearer ${session?.token}`,
'Content-Type': 'application/json', // For JSON requests
// No Content-Type for FormData (browser sets it automatically)
}Error Handling
Standard Error Pattern
typescript
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (e) {
console.error('API Error:', e);
toast.error('Something went wrong');
throw e;
}Error States in Components
typescript
const [error, setError] = useState<Error | null>(null);
if (error) {
return <ConnectionError error={error} onRetry={fetchData} />;
}API Response Normalization
The API uses MongoDB's _id field, but frontend often normalizes to id:
typescript
const json = await response.json();
// Normalize _id to id
const normalized = json.map((item) => {
const { _id, ...rest } = item;
return { id: _id, ...rest };
});Shared Links
Handling invitation/share links:
typescript
// src/server-actions/shareWithUser.ts
export default async function addUserToSharedLink(
linkId: string,
token: string
) {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/link/${linkId}/accept`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.json();
}File Downloads
For downloading ZIP archives:
typescript
// src/server-actions/zip-folders.ts
export async function downloadAsZip(items: string[], token: string) {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/download/zip`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items }),
}
);
const blob = await response.blob();
// Use file-saver to download
saveAs(blob, 'download.zip');
}