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
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
Create a .env.local file in the project root:
TTAPI_KEY=your_api_key_here
Get your key from ttapi.io after signing up.
Step 2 — The Backend API Route
We need two API routes:
-
POST /api/generate— submits the lyrics to Suno and returns ajobId -
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 });
}
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 }
);
}
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',
},
};
Step 4 — Run It
npm run dev
Open http://localhost:3000. You'll see:
- A title field
- Style preset buttons (Pop, Hip-Hop, Indie Folk, R&B, Rock, Custom)
- A lyrics textarea with section tag guidance
- 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
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
When prompted, add your environment variable:
TTAPI_KEY=your_api_key_here
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`,
}),
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
})
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
Resources
- Suno API Full Documentation — complete endpoint reference and parameter guide
- Suno API Quickstart — get your first song in 5 minutes
- Suno Music API Reference — all parameters for the generation endpoint
- Suno Lyrics API — generate lyrics before music
- Suno Extend API — make a track longer
- Suno Stems API — separate vocals and instrumentals
- Suno API Async Workflow — polling vs webhooks explained
What did you build with this? Share your app or a generated track in the comments.
Top comments (0)