DEV Community

yan zhiyu
yan zhiyu

Posted on

Build a Text-to-Song Web App with the Suno API (Lyrics In, Full Song Out)

You type a few lines of lyrics. You hit a button. Thirty seconds later, a complete song plays back — vocals, melody, instrumentation, the works.

That's what we're building in this tutorial: a minimal but fully functional text-to-song web app powered by the Suno API. Users paste in their own lyrics, pick a style, and get back a real AI-generated song with vocals.

What we're building:

  • A Next.js app with a simple two-field form: lyrics input + style tags
  • A backend API route that submits to Suno and polls for results
  • A frontend that streams status updates and plays the finished song inline

Time to complete: ~30 minutes

Stack: Next.js 14 (App Router), Node.js, Suno API via TTAPI

Prerequisites: Node.js 18+, a free TTAPI account


Why the Suno API for Lyrics-to-Song?

Most AI music tools treat lyrics as optional. Suno's custom mode does the opposite — you supply the exact lyrics, and the model builds the vocal melody, arrangement, and instrumentation around them. This makes it genuinely useful for:

  • Songwriters who want to hear a demo of their lyrics without recording anything
  • Content creators who want a branded jingle from a script
  • Developers building creative tools where the user's words become a song

The key parameter is custom: true combined with a structured prompt that contains your lyrics with section tags like [Verse], [Chorus], and [Bridge].

Full API reference: TTAPI Suno API Documentation


How the Suno Custom Lyrics Mode Works

Before writing code, it helps to understand the data flow:

User types lyrics + style tags
        ↓
POST /suno/v1/music   (custom: true)
        ↓
{ jobId: "abc123" }   (immediate response)
        ↓
GET /suno/v2/fetch?jobId=abc123   (poll every 5s)
        ↓
{ status: "SUCCESS", musics: [{ audioUrl: "..." }] }
        ↓
<audio> tag plays the result
Enter fullscreen mode Exit fullscreen mode

The Suno API is fully asynchronous — you never get the audio URL in the first response. You always store the jobId and fetch results separately. For more on this pattern, see the Suno API Async Workflow.


Step 1 — Project Setup

npx create-next-app@latest text-to-song --app --no-tailwind --no-src-dir
cd text-to-song
npm install
Enter fullscreen mode Exit fullscreen mode

Create a .env.local file in the project root:

TTAPI_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Get your key from ttapi.io after signing up.


Step 2 — The Backend API Route

We need two API routes:

  1. POST /api/generate — submits the lyrics to Suno and returns a jobId
  2. GET /api/result?jobId=xxx — fetches the job status and returns the audio URL when ready

Create app/api/generate/route.js:

const BASE_URL = 'https://api.ttapi.io';

export async function POST(request) {
  const { lyrics, tags, title } = await request.json();

  if (!lyrics || lyrics.trim().length < 10) {
    return Response.json(
      { error: 'Lyrics must be at least 10 characters.' },
      { status: 400 }
    );
  }

  const response = await fetch(`${BASE_URL}/suno/v1/music`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'TT-API-KEY': process.env.TTAPI_KEY,
    },
    body: JSON.stringify({
      custom: true,           // use the user's exact lyrics
      instrumental: false,    // we want vocals
      mv: 'chirp-v5',         // latest Suno v5 model
      title: title || 'My Song',
      tags: tags || 'pop, emotional, female vocal',
      prompt: lyrics,         // lyrics go here, with [Verse]/[Chorus] tags
    }),
  });

  const data = await response.json();

  if (data.status !== 'SUCCESS') {
    return Response.json(
      { error: `Suno submission failed: ${data.message}` },
      { status: 500 }
    );
  }

  return Response.json({ jobId: data.data.jobId });
}
Enter fullscreen mode Exit fullscreen mode

Now create app/api/result/route.js:

const BASE_URL = 'https://api.ttapi.io';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const jobId = searchParams.get('jobId');

  if (!jobId) {
    return Response.json({ error: 'jobId is required' }, { status: 400 });
  }

  const response = await fetch(
    `${BASE_URL}/suno/v2/fetch?jobId=${jobId}`,
    {
      headers: { 'TT-API-KEY': process.env.TTAPI_KEY },
    }
  );

  const data = await response.json();

  // Still processing
  if (data.status === 'ON_QUEUE' || data.status === 'PROCESSING') {
    return Response.json({ status: 'pending' });
  }

  // Done
  if (data.status === 'SUCCESS' && data.data?.musics?.length > 0) {
    const track = data.data.musics[0];
    return Response.json({
      status: 'done',
      audioUrl: track.audioUrl,
      title: track.title,
      duration: Math.round(track.duration),
      imageUrl: track.imageUrl,
    });
  }

  // Failed
  return Response.json(
    { status: 'failed', error: data.message || 'Generation failed.' },
    { status: 500 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — The Frontend

Replace the contents of app/page.js with:

'use client';

import { useState, useRef } from 'react';

const STYLE_PRESETS = [
  { label: 'Pop (Female vocal)', value: 'pop, emotional, female vocal, melodic' },
  { label: 'Hip-Hop', value: 'hip-hop, rap, trap beat, male vocal' },
  { label: 'Indie Folk', value: 'indie folk, acoustic guitar, soft vocal, warm' },
  { label: 'R&B', value: 'r&b, soul, smooth, female vocal, slow' },
  { label: 'Rock', value: 'rock, electric guitar, powerful vocal, energetic' },
  { label: 'Custom...', value: 'custom' },
];

const LYRICS_PLACEHOLDER = `[Verse]
Write your first verse here
Each line becomes part of the song
The AI will build a melody around your words

[Chorus]
This is the hook that repeats
Make it catchy and easy to sing
Your chorus will shine through

[Verse 2]
Second verse continues the story
More detail, more emotion here

[Chorus]
This is the hook that repeats
Make it catchy and easy to sing
Your chorus will shine through`;

export default function Home() {
  const [lyrics, setLyrics] = useState('');
  const [title, setTitle] = useState('');
  const [selectedPreset, setSelectedPreset] = useState(STYLE_PRESETS[0].value);
  const [customTags, setCustomTags] = useState('');
  const [status, setStatus] = useState('idle'); // idle | submitting | pending | done | failed
  const [result, setResult] = useState(null);
  const [error, setError] = useState('');
  const pollTimer = useRef(null);

  const tags = selectedPreset === 'custom' ? customTags : selectedPreset;

  async function handleGenerate() {
    if (!lyrics.trim()) {
      setError('Please enter some lyrics first.');
      return;
    }

    setError('');
    setResult(null);
    setStatus('submitting');

    // Submit the job
    const submitRes = await fetch('/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ lyrics, tags, title }),
    });

    const submitData = await submitRes.json();

    if (!submitRes.ok) {
      setError(submitData.error || 'Submission failed.');
      setStatus('failed');
      return;
    }

    const { jobId } = submitData;
    setStatus('pending');

    // Start polling
    pollTimer.current = setInterval(async () => {
      const pollRes = await fetch(`/api/result?jobId=${jobId}`);
      const pollData = await pollRes.json();

      if (pollData.status === 'done') {
        clearInterval(pollTimer.current);
        setResult(pollData);
        setStatus('done');
      } else if (pollData.status === 'failed') {
        clearInterval(pollTimer.current);
        setError(pollData.error || 'Generation failed.');
        setStatus('failed');
      }
      // if 'pending', keep polling
    }, 5000);
  }

  function handleReset() {
    clearInterval(pollTimer.current);
    setStatus('idle');
    setResult(null);
    setError('');
  }

  return (
    <main style={styles.main}>
      <h1 style={styles.h1}>Text to Song</h1>
      <p style={styles.subtitle}>
        Paste your lyrics, pick a style, and get a full AI-generated song in ~30 seconds.
      </p>

      {/* Song title */}
      <label style={styles.label}>Song Title</label>
      <input
        style={styles.input}
        type="text"
        placeholder="My Song"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        disabled={status !== 'idle'}
      />

      {/* Style picker */}
      <label style={styles.label}>Style</label>
      <div style={styles.presetGrid}>
        {STYLE_PRESETS.map((preset) => (
          <button
            key={preset.value}
            style={{
              ...styles.presetBtn,
              ...(selectedPreset === preset.value ? styles.presetBtnActive : {}),
            }}
            onClick={() => setSelectedPreset(preset.value)}
            disabled={status !== 'idle'}
          >
            {preset.label}
          </button>
        ))}
      </div>

      {selectedPreset === 'custom' && (
        <input
          style={styles.input}
          type="text"
          placeholder="e.g. jazz, saxophone, melancholic, slow"
          value={customTags}
          onChange={(e) => setCustomTags(e.target.value)}
          disabled={status !== 'idle'}
        />
      )}

      {/* Lyrics input */}
      <label style={styles.label}>
        Lyrics{' '}
        <span style={styles.hint}>
          Use [Verse], [Chorus], [Bridge] section tags for best results
        </span>
      </label>
      <textarea
        style={styles.textarea}
        placeholder={LYRICS_PLACEHOLDER}
        value={lyrics}
        onChange={(e) => setLyrics(e.target.value)}
        disabled={status !== 'idle'}
        rows={14}
      />

      {/* Error */}
      {error && <p style={styles.error}>{error}</p>}

      {/* Generate button */}
      {status === 'idle' || status === 'failed' ? (
        <button style={styles.btn} onClick={handleGenerate}>
          Generate Song
        </button>
      ) : status === 'submitting' ? (
        <button style={{ ...styles.btn, ...styles.btnDisabled }} disabled>
          Submitting...
        </button>
      ) : status === 'pending' ? (
        <button style={{ ...styles.btn, ...styles.btnDisabled }} disabled>
          Generating… (this takes ~30s)
        </button>
      ) : null}

      {/* Result */}
      {status === 'done' && result && (
        <div style={styles.resultCard}>
          {result.imageUrl && (
            <img src={result.imageUrl} alt={result.title} style={styles.coverArt} />
          )}
          <h2 style={styles.trackTitle}>{result.title}</h2>
          <p style={styles.trackMeta}>{result.duration}s · {tags}</p>
          <audio controls src={result.audioUrl} style={styles.player} />
          <div style={styles.resultActions}>
            <a href={result.audioUrl} download style={styles.downloadBtn}>
              Download MP3
            </a>
            <button style={styles.resetBtn} onClick={handleReset}>
              Generate Another
            </button>
          </div>
        </div>
      )}
    </main>
  );
}

const styles = {
  main: {
    maxWidth: 680,
    margin: '0 auto',
    padding: '48px 24px',
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
    color: '#111',
  },
  h1: { fontSize: 32, fontWeight: 700, marginBottom: 8 },
  subtitle: { color: '#555', marginBottom: 32, lineHeight: 1.5 },
  label: { display: 'block', fontWeight: 600, marginBottom: 8, marginTop: 24 },
  hint: { fontWeight: 400, color: '#888', fontSize: 13 },
  input: {
    width: '100%',
    padding: '10px 14px',
    fontSize: 15,
    border: '1px solid #ddd',
    borderRadius: 8,
    boxSizing: 'border-box',
    outline: 'none',
  },
  textarea: {
    width: '100%',
    padding: '12px 14px',
    fontSize: 14,
    fontFamily: 'monospace',
    border: '1px solid #ddd',
    borderRadius: 8,
    boxSizing: 'border-box',
    resize: 'vertical',
    lineHeight: 1.6,
  },
  presetGrid: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 4 },
  presetBtn: {
    padding: '8px 14px',
    borderRadius: 20,
    border: '1px solid #ddd',
    background: '#fff',
    cursor: 'pointer',
    fontSize: 13,
  },
  presetBtnActive: {
    background: '#111',
    color: '#fff',
    borderColor: '#111',
  },
  btn: {
    marginTop: 24,
    width: '100%',
    padding: '14px',
    background: '#111',
    color: '#fff',
    border: 'none',
    borderRadius: 8,
    fontSize: 16,
    fontWeight: 600,
    cursor: 'pointer',
  },
  btnDisabled: { background: '#888', cursor: 'not-allowed' },
  error: { color: '#c00', marginTop: 12, fontSize: 14 },
  resultCard: {
    marginTop: 32,
    padding: 24,
    border: '1px solid #eee',
    borderRadius: 12,
    background: '#fafafa',
  },
  coverArt: { width: '100%', borderRadius: 8, marginBottom: 16 },
  trackTitle: { fontSize: 22, fontWeight: 700, margin: '0 0 4px' },
  trackMeta: { color: '#888', fontSize: 13, marginBottom: 16 },
  player: { width: '100%', marginBottom: 16 },
  resultActions: { display: 'flex', gap: 12 },
  downloadBtn: {
    flex: 1,
    padding: '10px 0',
    background: '#111',
    color: '#fff',
    textAlign: 'center',
    borderRadius: 8,
    textDecoration: 'none',
    fontWeight: 600,
    fontSize: 14,
  },
  resetBtn: {
    flex: 1,
    padding: '10px 0',
    background: '#fff',
    border: '1px solid #ddd',
    borderRadius: 8,
    fontWeight: 600,
    fontSize: 14,
    cursor: 'pointer',
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 4 — Run It

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000. You'll see:

  1. A title field
  2. Style preset buttons (Pop, Hip-Hop, Indie Folk, R&B, Rock, Custom)
  3. A lyrics textarea with section tag guidance
  4. A Generate button

Paste in some lyrics with [Verse] and [Chorus] tags, hit Generate, and wait ~30 seconds. The result card appears with cover art, an inline audio player, and a download button.


Lyrics Formatting Guide

The prompt field in custom mode is where your lyrics go. Suno reads section tags to understand song structure. Here's what works:

Tag What it does
[Verse] Standard verse section
[Chorus] Repeated hook — use identical text each time for a real chorus
[Bridge] Contrasting mid-section, usually appears once
[Intro] Opening instrumental or vocal section
[Outro] Closing section
[Pre-Chorus] Build-up before the chorus

Formatting tips:

  • Repeat the [Chorus] block verbatim 2–3 times in the prompt — Suno will recognize it as a repeating section
  • Keep lines short (4–8 words). Long lines get cut off or rushed
  • Avoid special characters, emoji, or punctuation-heavy lines
  • The more natural the line rhythm, the better the vocal melody

Example prompt structure:

[Verse]
Walking down an empty street
Rain against the windowpane
Everything I thought I'd keep
Slowly washing down the drain

[Chorus]
Let it go, let it go
Nothing left to hold on to
Let it go, let it go
I was never meant for you

[Verse 2]
Found your jacket on the chair
Packed it up without a word
Left the key beneath the stair
Said the things that went unheard

[Chorus]
Let it go, let it go
Nothing left to hold on to
Let it go, let it go
I was never meant for you

[Bridge]
Maybe someday I'll look back
See this moment for what it was
No regret and no attack
Just the end of what once was

[Chorus]
Let it go, let it go
Nothing left to hold on to
Let it go, let it go
I was never meant for you
Enter fullscreen mode Exit fullscreen mode

Style Tags Reference

The tags field defines the musical feel. Some combinations that produce strong results:

Genre Tags
Emotional pop pop, emotional, female vocal, piano, melodic
Hip-hop hip-hop, trap, 808, male vocal, punchy
Indie folk indie folk, acoustic guitar, warm, soft vocal
R&B / soul r&b, soul, smooth, female vocal, minor key
Rock anthem rock, electric guitar, powerful, male vocal, stadium
Country country, storytelling, acoustic, twang, heartfelt
Jazz vocal jazz, upright bass, brushed drums, female vocal, intimate

Mix 4–6 tags for best results. Avoid contradictory combinations like fast + slow or male vocal + female vocal.


Step 5 — Deploying to Vercel

The app is ready to deploy as-is:

npm install -g vercel
vercel
Enter fullscreen mode Exit fullscreen mode

When prompted, add your environment variable:

TTAPI_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Vercel will give you a live URL in under a minute.

One thing to note: Next.js API routes have a default 10-second timeout on Vercel's Hobby plan. Our polling is done client-side (the browser polls /api/result every 5 seconds), so this isn't a problem — each individual API call completes well within the limit.


Going Further

Webhook instead of polling

For a production app, replace the client-side polling loop with a server-side webhook. Pass a hookUrl pointing to a new API route when submitting the job:

body: JSON.stringify({
  custom: true,
  instrumental: false,
  mv: 'chirp-v5',
  title,
  tags,
  prompt: lyrics,
  hookUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/api/webhook`,
}),
Enter fullscreen mode Exit fullscreen mode

Then handle the inbound POST in /api/webhook/route.js and push the result to your frontend via a websocket or server-sent event. See Suno API Async Workflow for the full webhook payload shape.

Generate lyrics first

If your users want help writing the lyrics before generating the song, use the Suno Lyrics API to generate structured lyrics from a topic, then feed the result directly into the music generation step.

Extend the track

Suno's default generation is around 2–3 minutes. To produce a longer version, take the musicId from the result and pass it to the Suno Extend API:

body: JSON.stringify({
  defaultParamFlag: 1,
  musicId: track.musicId,
  continueAt: track.duration - 5, // overlap last 5 seconds
})
Enter fullscreen mode Exit fullscreen mode

Stems separation

After generation, use the Suno Stems API to split the track into a vocal stem and an instrumental stem — useful if users want to download just the acapella or just the backing track.


Complete File Structure

text-to-song/
├── app/
│   ├── api/
│   │   ├── generate/
│   │   │   └── route.js      ← submits to Suno API
│   │   └── result/
│   │       └── route.js      ← polls for job result
│   ├── page.js               ← full UI
│   └── layout.js             ← default Next.js layout
├── .env.local                ← TTAPI_KEY goes here
└── package.json
Enter fullscreen mode Exit fullscreen mode

Resources


What did you build with this? Share your app or a generated track in the comments.

Top comments (0)