Skip to content

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 config

API 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 ZIP

API Endpoints

Artist Endpoints

MethodEndpointDescription
GET/artistList user's artists
GET/artist/:idGet artist details
POST/artistCreate artist
PUT/artist/:idUpdate artist
DELETE/artist/:idDelete artist
GET/project/artistArtists from project collaborations
GET/track/artistArtists from track collaborations

Project Endpoints

MethodEndpointDescription
GET/project/:idGet project details
POST/projectCreate project
PUT/project/:idUpdate project
DELETE/project/:idDelete project

Track Endpoints

MethodEndpointDescription
GET/track/:idGet track details
GET/track/project/:projectIdList project tracks
POST/trackCreate track
PUT/track/:idUpdate track
DELETE/track/:idDelete track
POST/track/:id/fileUpload audio version
POST/track/:id/attachmentsUpload attachments

User/Auth Endpoints

MethodEndpointDescription
POST/user/signinEmail/password login
POST/user/signupRegister new user
GET/user/auth/google/callbackGoogle OAuth callback
GET/user/profileGet user profile
PUT/user/profileUpdate profile

Other Endpoints

MethodEndpointDescription
GET/link/:idGet shared link details
GET/searchSearch artists/projects/tracks
GET/trashList deleted items
POST/trash/restore/:idRestore 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 };
});

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');
}

Ctrl-Audio Platform Documentation