AI Writing Assistant with Next.js

Build a Next.js app that streams AI-powered text transformations with Gemini.

Introduction

30 Second Summary

Everyone writes. Emails, reports, messages, cover letters. Sometimes you stare at a paragraph and know it could be better, but you're not sure how to fix it.

In this project, you will build an AI writing assistant web app that transforms your text at the click of a button. Paste in any text, choose an action like "Improve" or "Make Concise," and watch the AI rewrite it in real-time.

What You'll Build

A polished AI Writing Assistant built with Next.js and Google Gemini that streams text transformations token-by-token.

By the end of this project, you'll have:

  • A Next.js application with a writing interface and AI-powered text transformation.
  • An API route that streams responses from Google Gemini.
  • A styled React UI with action buttons for different writing operations like improve, concise, expand, and change tone.
  • Secret Mission: Add a custom prompt builder where users can write their own instructions for unlimited transformations.

Are there any prerequisites?

No prior experience with React or AI APIs is needed. This guide walks you through every step from scratch, including installing all required tools. You just need VS Code and a free Gemini API key (covered in Step 1).

Set Up Your Development Environment

To build your AI writing assistant, you need a web application framework that handles both the frontend interface and the backend API. You also need a way to stream responses from Google Gemini so the text appears word by word.

In this step, you will scaffold a Next.js project and install the Vercel AI SDK. Next.js gives you a React frontend with a built-in API server. The AI SDK provides a streaming connection to Gemini in just a few lines of code.

In this step, get ready to:
  • Scaffold a Next.js project for your writing assistant.
  • Install the AI SDK packages for streaming Gemini responses.
  • Store your API key in an environment file.
Scaffold your Next.js project

Next.js includes everything you need for this project: a React UI layer, a built-in API route system, and a development server with hot reloading. The scaffold command creates all of this in one go.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick, the key below Escape).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest writing-assistant --js --tailwind --eslint --app --use-npm

What does this command do?

  • `npx create-next-app@latest` downloads and runs the latest Next.js project scaffolder.
  • `writing-assistant` is the name of your project folder.
  • `--js` uses JavaScript instead of the default TypeScript, keeping things simpler for this project.
  • `--tailwind` includes Tailwind CSS for styling.
  • `--app` uses the modern App Router. `--use-npm` sets npm as the package manager.

Seeing a prompt about installing create-next-app?

If npx asks you to confirm installing create-next-app, type y and press Enter to continue.

  • Move into your new project folder by running:
cd writing-assistant
  • Confirm the project files exist by running:
ls

You should see folders like app/, node_modules/, public/, and files like package.json and next.config.mjs in the output.

Don't see these files?

  • Make sure you ran cd writing-assistant after the scaffold completed. If you run ls from the Desktop, you will only see the project folder itself.
  • Check your terminal for error messages during the scaffold. Network issues can interrupt the download.

Help me troubleshoot my Next.js scaffold

Install the AI SDK packages

The Vercel AI SDK provides a standardized way to stream responses from AI models. The ai package handles the streaming protocol. The @ai-sdk/google package connects specifically to Google Gemini.

  • Install both packages by running this command:
npm install ai @ai-sdk/google
  • Wait for the install to finish. You should see a summary of packages added with no error messages.

Seeing permission errors or network failures?

  • Double check you are inside the writing-assistant folder. Run pwd to confirm your current location ends with /writing-assistant.
  • If you see network errors, check your internet connection and try the command again.

Help me fix my npm install error

Store your Gemini API key

The AI SDK automatically reads your Gemini API key from an environment variable called GOOGLE_GENERATIVE_AI_API_KEY. You store this in a .env.local file that Next.js loads at startup. This keeps your key out of your code.

  • Create the environment file by running this command:
touch .env.local
  • Open .env.local in VS Code by running:
code .env.local
  • Paste the following into the file, replacing your-api-key-here with the Gemini API key you have on hand:
GOOGLE_GENERATIVE_AI_API_KEY=your-api-key-here

What is .env.local?

Next.js automatically loads variables from .env.local into your server-side code. The .local suffix means this file is ignored by Git, so your key never gets committed to version control.

The AI SDK looks for this exact variable name (GOOGLE_GENERATIVE_AI_API_KEY) automatically. You do not need to import or reference it in your code.

  • Save .env.local.
  • Confirm the file exists by running:
ls -a

You should see .env.local in the list. The -a flag shows hidden files (those starting with a dot).

✔️ Awesome, I've got everything!

Great. Double check you have saved .env.local with your actual API key pasted in.

ⓧ I'd like to double check the full code

Here is the complete .env.local file. Make sure your version matches this structure, with your real key replacing the placeholder:

GOOGLE_GENERATIVE_AI_API_KEY=your-api-key-here

Your development environment is ready. You have a Next.js project with the AI SDK installed and your Gemini API key securely stored. Next up, you will build the backend API route that streams AI responses.

Build the AI Backend Route

Your development environment is ready. Now it is time to build the brain of your writing assistant.

In this step, you will create an API route that receives text from the browser, applies a specific writing instruction to Gemini, and streams the improved text back. This route is where all the AI magic happens.

In this step, get ready to:
  • Create an API route file that accepts text and an action type.
  • Configure system prompts for different writing actions.
  • Implement streaming with the Vercel AI SDK.
Create the API route file

Next.js uses a file-based routing system. Any file named route.js inside the app/ directory automatically becomes an API endpoint at that URL path.

You will create a file at app/api/improve/route.js, which means your frontend can send requests to /api/improve.

  • In VS Code's Explorer sidebar, right-click on the app folder.
  • Click New File.
  • Type api/improve/route.js as the file name and press Enter.

What does this path do?

VS Code automatically creates the api/ and improve/ folders for you when you type the full path. The final file structure is app/api/improve/route.js.

  • Add the following code to your new route.js file:
import { google } from '@ai-sdk/google';
import { streamText } from 'ai';

export async function POST(req) {
  const { text, action, customInstruction } = await req.json();
}

What does this code do?

import { google } from '@ai-sdk/google' loads the Google provider from the Vercel AI SDK. This is what connects your app to the Gemini API.

export async function POST(req) tells Next.js this file handles POST requests. The function destructures text, action, and customInstruction from the incoming request body.

  • Confirm the file appears in your Explorer sidebar under app > api > improve > route.js.

Don't see the nested folders?

Make sure you typed api/improve/route.js (with forward slashes) when creating the file. If you accidentally created a flat file named api/improve/route.js without folders, delete it and try again.

help me create nested folders in VS Code

Configure writing-focused system prompts

A system prompt tells the AI model how to behave. By defining different prompts for each action (improve, concise, expand, tone), your app can transform text in multiple ways using the same route.

  • In route.js, find the line that says const { text, action, customInstruction } = await req.json(); and add the following code below it:
  const systemPrompts = {
    improve:
      'You are a writing assistant. Improve the following text to be clearer, more engaging, and better structured. Return only the improved text without explanations.',
    concise:
      'You are a writing assistant. Make the following text more concise while keeping its meaning. Remove filler words and unnecessary phrases. Return only the concise version.',
    expand:
      'You are a writing assistant. Expand the following text with more detail, examples, and richer language. Return only the expanded text.',
    tone: 'You are a writing assistant. Rewrite the following text in a professional, formal tone. Return only the rewritten text.',
    custom: customInstruction || 'You are a writing assistant. Improve the following text.',
  };

  const systemPrompt = systemPrompts[action] || systemPrompts.improve;

What does this code do?

The systemPrompts object maps each action name to a specific instruction for Gemini. When the frontend sends action: 'concise', the route looks up the matching prompt and passes it to the model.

The custom key uses the customInstruction value from the request body. This is what the Secret Mission will use later. The fallback || systemPrompts.improve ensures the route still works even if an unknown action is sent.

You will not see a visible change yet. The next chunk adds the streaming logic that completes the route.

Implement streaming with streamText

Streaming sends the AI response back to the browser token-by-token instead of waiting for the entire response to finish. This makes the app feel fast and responsive, even for long outputs.

  • In route.js, find the line that says const systemPrompt = systemPrompts[action] || systemPrompts.improve; and add the following code below it:
  const result = streamText({
    model: google('gemini-2.5-flash'),
    system: systemPrompt,
    prompt: text,
  });

  return result.toDataStreamResponse();

What does this code do?

streamText() calls the Gemini model with your system prompt and the user's text. The model: google('gemini-2.5-flash') line tells the AI SDK which model to use.

toDataStreamResponse() converts the streaming result into an HTTP response that sends text chunks to the browser as they are generated. The browser will receive each piece of improved text in real-time.

  • Save route.js.
  • Start the development server by running this command in your terminal:
npm run dev
  • You should see output that includes Ready and a local URL (usually http://localhost:3000). This confirms your route file compiles without errors.

Seeing a compilation error?

Check that your import statements at the top of route.js match exactly: import { google } from '@ai-sdk/google' and import { streamText } from 'ai'. A missing quote or curly brace will cause the error.

Also verify that all opening braces { have a matching closing brace }. The function and the systemPrompts object each need their own closing brace.

help me fix a compilation error in my Next.js API route

✔️ Awesome, I've got everything!

Great. Double check you have saved route.js and your dev server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete app/api/improve/route.js file. Make sure yours matches exactly:

import { google } from '@ai-sdk/google';
import { streamText } from 'ai';

export async function POST(req) {
  const { text, action, customInstruction } = await req.json();

  const systemPrompts = {
    improve:
      'You are a writing assistant. Improve the following text to be clearer, more engaging, and better structured. Return only the improved text without explanations.',
    concise:
      'You are a writing assistant. Make the following text more concise while keeping its meaning. Remove filler words and unnecessary phrases. Return only the concise version.',
    expand:
      'You are a writing assistant. Expand the following text with more detail, examples, and richer language. Return only the expanded text.',
    tone: 'You are a writing assistant. Rewrite the following text in a professional, formal tone. Return only the rewritten text.',
    custom: customInstruction || 'You are a writing assistant. Improve the following text.',
  };

  const systemPrompt = systemPrompts[action] || systemPrompts.improve;

  const result = streamText({
    model: google('gemini-2.5-flash'),
    system: systemPrompt,
    prompt: text,
  });

  return result.toDataStreamResponse();
}

Your backend is live and ready to receive text. Next up, you will build the writing interface that sends text to this route and displays the AI-improved results.

Build the Writing Interface

Your API backend is ready to accept text and stream back improvements from Gemini.

Now you need the other half of the equation: a frontend page where users type their text, send it to your API route, and see the AI response appear in real time.

In this step, get ready to:
  • Create the main page component with state management.
  • Write a streaming handler that reads AI responses token by token.
  • Build the textarea and result display layout.
Create the page component with state management

Every React component that responds to user input needs state to track what the user has typed and what the app is doing.

Your writing assistant needs four pieces of state: the text the user typed in, the AI's output, whether a request is loading, and which action button is currently active.

  • In VS Code, open app/page.js from the sidebar. This file already exists because Next.js scaffolded it for you.
  • Select all the existing code and replace it with this:
'use client';

import { useState } from 'react';

export default function Home() {
  // Track what the user types
  const [inputText, setInputText] = useState('');
  // Track the AI's response
  const [outputText, setOutputText] = useState('');
  // Track whether a request is in progress
  const [isLoading, setIsLoading] = useState(false);
  // Track which action button is active
  const [activeAction, setActiveAction] = useState(null);

  // Define the available writing actions
  const actions = [
    { id: 'improve', label: 'Improve', description: 'Make it clearer and more engaging' },
    { id: 'concise', label: 'Concise', description: 'Shorten without losing meaning' },
    { id: 'expand', label: 'Expand', description: 'Add more detail and examples' },
    { id: 'tone', label: 'Formal Tone', description: 'Make it professional' },
  ];
}

What does this code do?

'use client' tells Next.js this component runs in the browser. Without it, React hooks like useState would fail because they only work on the client side.

Each useState call creates a variable and a function to update it. When you call the setter (like setInputText), React re-renders the component with the new value.

The actions array defines the writing transformations available to users. You will render these as buttons in a later step.

  • Save app/page.js.

You won't see a visible change yet because the component doesn't return any JSX. The next chunk adds the handler function, and the chunk after that builds the UI.

Wire up the streaming handler

When a user clicks an action button, your app needs to POST their text to the API route and read the streaming response chunk by chunk.

The Vercel AI SDK sends streamed text in a specific format where each text chunk is on a line starting with 0:. Your handler will parse these lines and update the output in real time.

  • In app/page.js, find the closing bracket of the actions array (];), then paste the following code below it:
  async function handleAction(action) {
    // Don't submit if the input is empty
    if (!inputText.trim()) return;

    // Set loading state and clear previous output
    setActiveAction(action);
    setIsLoading(true);
    setOutputText('');

    // Send the text and action to our API route
    const response = await fetch('/api/improve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: inputText, action }),
    });

    // Read the streaming response
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let fullText = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      // Decode each chunk and look for text lines
      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        // AI SDK text chunks start with '0:'
        if (line.startsWith('0:')) {
          const text = JSON.parse(line.slice(2));
          fullText += text;
          setOutputText(fullText);
        }
      }
    }

    // Done streaming
    setIsLoading(false);
    setActiveAction(null);
  }

  function handleCopy() {
    navigator.clipboard.writeText(outputText);
  }

What does this code do?

handleAction is the core of your app. It sends a POST request to /api/improve with the user's text and chosen action, then reads the response as a stream.

response.body.getReader() gives you a readable stream. The while (true) loop reads chunks until the stream ends. Each chunk may contain multiple lines, and lines starting with 0: carry the actual text tokens from the AI.

Every time a new token arrives, setOutputText(fullText) updates the state. React re-renders with the new text, creating the typewriter effect you see in tools like ChatGPT.

  • Save app/page.js.

You won't see a change yet because the component still doesn't return any visible UI. The next chunk adds the layout.

Add the textarea and result display

With state and logic in place, you can build the interface. The layout uses a two-column grid: a textarea on the left for input and a result panel on the right for the AI's streamed output.

  • In app/page.js, find the handleCopy function's closing brace (}), then paste the following code below it (still inside the Home function):
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">AI Writing Assistant</h1>
        <p className="text-gray-600 mb-8">
          Paste your text below and choose an action to transform it.
        </p>

        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          {/* Left column: text input */}
          <div className="space-y-4">
            <label className="block text-sm font-medium text-gray-700">Your Text</label>
            <textarea
              value={inputText}
              onChange={(e) => setInputText(e.target.value)}
              placeholder="Paste or type your text here..."
              className="w-full h-64 p-4 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <p className="text-sm text-gray-500">{inputText.length} characters</p>
          </div>

          {/* Right column: AI result */}
          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <label className="block text-sm font-medium text-gray-700">Result</label>
              {outputText && (
                <button
                  onClick={handleCopy}
                  className="text-sm text-blue-600 hover:text-blue-800"
                >
                  Copy to clipboard
                </button>
              )}
            </div>
            <div className="w-full h-64 p-4 border border-gray-300 rounded-lg bg-white overflow-y-auto">
              {isLoading && !outputText && (
                <p className="text-gray-400 animate-pulse">Thinking...</p>
              )}
              {outputText && (
                <p className="text-gray-800 whitespace-pre-wrap">{outputText}</p>
              )}
              {!isLoading && !outputText && (
                <p className="text-gray-400">
                  Your improved text will appear here...
                </p>
              )}
            </div>
            <p className="text-sm text-gray-500">{outputText.length} characters</p>
          </div>
        </div>
      </div>
    </main>
  );

What does this code do?

The textarea is bound to inputText state via value and onChange. Every keystroke updates the state, and React re-renders with the new value. This is called a controlled component.

The result panel uses conditional rendering. When isLoading is true and no output exists yet, it shows a pulsing "Thinking..." message. When outputText has content, it displays the streamed text. Otherwise it shows placeholder text.

The "Copy to clipboard" button only appears when there is output to copy, thanks to the {outputText && (...)} pattern.

  • Save app/page.js.
  • Start the dev server by running this command in your terminal:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see a two-column layout with a textarea on the left labeled "Your Text" and a result panel on the right labeled "Result". The character count below each panel should read 0.

Seeing an error or blank page?

  • Check that your file starts with 'use client'; on its own line. Missing this directive causes a server component error.
  • Make sure every opening brace, parenthesis, and JSX tag has a matching close. A single missing } breaks the entire file.
  • Confirm you removed ALL of the original page.js content before pasting. Leftover code from the scaffold will conflict.

Still stuck? Help me debug my page.js file.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.js and your dev server is running.

ⓧ I'd like to double check the full code

Here is the complete app/page.js file at the end of this step:

'use client';

import { useState } from 'react';

export default function Home() {
  const [inputText, setInputText] = useState('');
  const [outputText, setOutputText] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [activeAction, setActiveAction] = useState(null);

  const actions = [
    { id: 'improve', label: 'Improve', description: 'Make it clearer and more engaging' },
    { id: 'concise', label: 'Concise', description: 'Shorten without losing meaning' },
    { id: 'expand', label: 'Expand', description: 'Add more detail and examples' },
    { id: 'tone', label: 'Formal Tone', description: 'Make it professional' },
  ];

  async function handleAction(action) {
    if (!inputText.trim()) return;

    setActiveAction(action);
    setIsLoading(true);
    setOutputText('');

    const response = await fetch('/api/improve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: inputText, action }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let fullText = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (line.startsWith('0:')) {
          const text = JSON.parse(line.slice(2));
          fullText += text;
          setOutputText(fullText);
        }
      }
    }

    setIsLoading(false);
    setActiveAction(null);
  }

  function handleCopy() {
    navigator.clipboard.writeText(outputText);
  }

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">AI Writing Assistant</h1>
        <p className="text-gray-600 mb-8">
          Paste your text below and choose an action to transform it.
        </p>

        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <div className="space-y-4">
            <label className="block text-sm font-medium text-gray-700">Your Text</label>
            <textarea
              value={inputText}
              onChange={(e) => setInputText(e.target.value)}
              placeholder="Paste or type your text here..."
              className="w-full h-64 p-4 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <p className="text-sm text-gray-500">{inputText.length} characters</p>
          </div>

          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <label className="block text-sm font-medium text-gray-700">Result</label>
              {outputText && (
                <button
                  onClick={handleCopy}
                  className="text-sm text-blue-600 hover:text-blue-800"
                >
                  Copy to clipboard
                </button>
              )}
            </div>
            <div className="w-full h-64 p-4 border border-gray-300 rounded-lg bg-white overflow-y-auto">
              {isLoading && !outputText && (
                <p className="text-gray-400 animate-pulse">Thinking...</p>
              )}
              {outputText && (
                <p className="text-gray-800 whitespace-pre-wrap">{outputText}</p>
              )}
              {!isLoading && !outputText && (
                <p className="text-gray-400">
                  Your improved text will appear here...
                </p>
              )}
            </div>
            <p className="text-sm text-gray-500">{outputText.length} characters</p>
          </div>
        </div>
      </div>
    </main>
  );
}

Your writing interface is live. Next up, you will add the action buttons that let users choose how to transform their text and see streaming results appear in real time.

Add Writing Action Buttons and Streaming Display

Your writing assistant has a text input area and a result display panel, but there is no way for the user to actually trigger an AI transformation.

In this step, you will add action buttons that let the user choose how they want their text transformed. Once clicked, these buttons trigger the streaming response you built in the previous step, and the result appears token-by-token in the display panel.

In this step, get ready to:
  • Render action buttons that trigger different writing transformations.
  • Test the full streaming flow end-to-end with real text.
Render the action buttons

Your actions array already defines four writing operations. Now you need to render them as clickable buttons that call handleAction with the correct action ID.

  • In app/page.js, scroll down to the return statement and find the closing </div> that ends the two-column grid (the element with className grid grid-cols-1 md:grid-cols-2 gap-6).
  • Add the following action buttons section directly below that closing </div>:
        <div className="mt-8">
          <p className="text-sm font-medium text-gray-700 mb-3">Choose an action:</p>
          <div className="flex flex-wrap gap-3">
            {actions.map((action) => (
              <button
                key={action.id}
                onClick={() => handleAction(action.id)}
                disabled={isLoading || !inputText.trim()}
                className={`px-4 py-2 rounded-lg font-medium transition-colors ${
                  activeAction === action.id
                    ? 'bg-blue-600 text-white'
                    : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
                } disabled:opacity-50 disabled:cursor-not-allowed`}
                title={action.description}
              >
                {activeAction === action.id ? 'Processing...' : action.label}
              </button>
            ))}
          </div>
        </div>

What does this code do?

This section maps over the actions array and renders a button for each writing operation. Each button calls handleAction(action.id) when clicked, passing the action type to your API route.

The disabled prop prevents clicks while a request is in progress or when the textarea is empty. The className uses a ternary expression to highlight the active button in blue while the response is streaming.

The button text switches to Processing... while that action is running, giving users clear feedback about which operation is active.

How does the streaming display work?

When a button is clicked, handleAction sets isLoading to true and clears the output. The result panel shows "Thinking..." with a pulse animation while waiting for the first token.

As tokens arrive from Gemini, setOutputText updates the displayed text incrementally. The user sees the response build up character by character, making the app feel fast even on longer transformations.

  • Save app/page.js.

Seeing a syntax error in the terminal?

Make sure the new <div className="mt-8"> block sits between the grid's closing </div> and the max-w-4xl wrapper's closing </div>. An extra or missing closing tag will break the JSX.

Also check that the template literal backticks in the className prop are not accidentally replaced with single quotes.

Help me debug a JSX syntax error in my action buttons

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/page.js at this point:

'use client';

import { useState } from 'react';

export default function Home() {
  const [inputText, setInputText] = useState('');
  const [outputText, setOutputText] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [activeAction, setActiveAction] = useState(null);

  const actions = [
    { id: 'improve', label: 'Improve', description: 'Make it clearer and more engaging' },
    { id: 'concise', label: 'Concise', description: 'Shorten without losing meaning' },
    { id: 'expand', label: 'Expand', description: 'Add more detail and examples' },
    { id: 'tone', label: 'Formal Tone', description: 'Make it professional' },
  ];

  async function handleAction(action) {
    if (!inputText.trim()) return;

    setActiveAction(action);
    setIsLoading(true);
    setOutputText('');

    const response = await fetch('/api/improve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: inputText, action }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let fullText = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (line.startsWith('0:')) {
          const text = JSON.parse(line.slice(2));
          fullText += text;
          setOutputText(fullText);
        }
      }
    }

    setIsLoading(false);
    setActiveAction(null);
  }

  function handleCopy() {
    navigator.clipboard.writeText(outputText);
  }

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">AI Writing Assistant</h1>
        <p className="text-gray-600 mb-8">
          Paste your text below and choose an action to transform it.
        </p>

        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <div className="space-y-4">
            <label className="block text-sm font-medium text-gray-700">Your Text</label>
            <textarea
              value={inputText}
              onChange={(e) => setInputText(e.target.value)}
              placeholder="Paste or type your text here..."
              className="w-full h-64 p-4 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <p className="text-sm text-gray-500">{inputText.length} characters</p>
          </div>

          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <label className="block text-sm font-medium text-gray-700">Result</label>
              {outputText && (
                <button
                  onClick={handleCopy}
                  className="text-sm text-blue-600 hover:text-blue-800"
                >
                  Copy to clipboard
                </button>
              )}
            </div>
            <div className="w-full h-64 p-4 border border-gray-300 rounded-lg bg-white overflow-y-auto">
              {isLoading && !outputText && (
                <p className="text-gray-400 animate-pulse">Thinking...</p>
              )}
              {outputText && (
                <p className="text-gray-800 whitespace-pre-wrap">{outputText}</p>
              )}
              {!isLoading && !outputText && (
                <p className="text-gray-400">
                  Your improved text will appear here...
                </p>
              )}
            </div>
            <p className="text-sm text-gray-500">{outputText.length} characters</p>
          </div>
        </div>

        <div className="mt-8">
          <p className="text-sm font-medium text-gray-700 mb-3">Choose an action:</p>
          <div className="flex flex-wrap gap-3">
            {actions.map((action) => (
              <button
                key={action.id}
                onClick={() => handleAction(action.id)}
                disabled={isLoading || !inputText.trim()}
                className={`px-4 py-2 rounded-lg font-medium transition-colors ${
                  activeAction === action.id
                    ? 'bg-blue-600 text-white'
                    : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
                } disabled:opacity-50 disabled:cursor-not-allowed`}
                title={action.description}
              >
                {activeAction === action.id ? 'Processing...' : action.label}
              </button>
            ))}
          </div>
        </div>
      </div>
    </main>
  );
}
Test the full writing assistant

With the buttons in place, every piece of your writing assistant is connected. The textarea captures input, the buttons trigger the API call, and the result panel streams the response in real-time.

  • Start your development server by running this command in the terminal:
npm run dev
  • Open http://localhost:3000 in your browser.
  • Paste or type a few sentences into the textarea on the left. For example: The meeting was really long and we talked about a lot of stuff that was kind of important but also some things that weren't.
  • Click the Improve button.

You should see the button turn blue and display "Processing...", the result panel show "Thinking..." with a pulsing animation, and then the improved text stream in word by word.

  • Try clicking Concise and Expand to see how the same text transforms differently based on the action.
  • Notice the character count below both panels updates as text changes.

Why dedicated buttons instead of a chat interface?

A generic chatbot requires users to describe what they want every time. Dedicated action buttons turn common writing tasks into single clicks.

This pattern also teaches you how to pass different system prompts from the frontend to the same backend endpoint. Production AI apps use this approach to build domain-specific tools rather than generic chat wrappers.

Buttons are disabled and nothing happens?

The buttons stay disabled until the textarea has text. Type or paste something into the left panel first.

If clicking a button shows "Thinking..." but nothing streams in, check your terminal for errors. The most common issue is a missing or invalid GOOGLE_GENERATIVE_AI_API_KEY in your .env.local file. Restart the dev server after updating the key.

Help me debug why my writing assistant buttons aren't triggering a response

Your writing assistant is fully functional. You can transform any text with a single click and watch the results stream in live. Next up, you will polish the UI to make it look like a professional writing tool.

Style and Polish the UI

Your writing assistant already works. It streams AI responses in real-time and handles multiple action types. But a polished tool also needs visual clarity so the user always knows what is happening.

In this step, you will walk through the Tailwind CSS styling already in your code, understand the visual feedback patterns, and test the full app end-to-end with real writing samples.

In this step, get ready to:
  • Review the Tailwind CSS styling that creates a two-column professional layout.
  • Understand the visual feedback features: active button highlighting, loading animation, and copy-to-clipboard.
  • Test the full writing assistant end-to-end with different text samples.
Review your Tailwind layout and styling

Your app/page.js file uses Tailwind CSS utility classes to create a professional writing tool layout without writing any custom CSS.

  • Switch back to app/page.js in VS Code.
  • Look at the outer <main> element near the bottom of the file. Notice the Tailwind classes applied to it:
<main className="min-h-screen bg-gray-50 py-12 px-4">

What do these classes do?

Tailwind classes are small, single-purpose utilities you combine to build a design. Here is what each one does:

  • min-h-screen makes the page fill at least the full viewport height.
  • bg-gray-50 applies a subtle off-white background so content panels stand out.
  • py-12 px-4 adds vertical and horizontal padding to keep content away from the edges.
  • Now look at the grid container that holds the input and output panels:
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">

How does the responsive grid work?

grid grid-cols-1 stacks the panels in a single column on small screens (like phones). md:grid-cols-2 switches to a two-column side-by-side layout on medium screens and above.

This means your writing assistant looks great on both desktop and mobile without any extra work.

Understand the visual feedback features

Good writing tools give users constant feedback about what is happening. Your app has three key feedback mechanisms already built in.

  • Find the handleCopy function near the bottom of your component logic:
function handleCopy() {
  navigator.clipboard.writeText(outputText);
}

How does copy-to-clipboard work?

navigator.clipboard.writeText() is a built-in browser API that copies text to the user's clipboard. The copy button only appears when outputText is non-empty, so users never see a useless button.

  • Next, find the loading indicator inside the result panel:
{isLoading && !outputText && (
  <p className="text-gray-400 animate-pulse">Thinking...</p>
)}

What is animate-pulse?

animate-pulse is a Tailwind animation that gently fades the text in and out. It tells the user that the AI is working without blocking the interface.

  • Finally, look at the action button styling near the bottom of your JSX:
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
  activeAction === action.id
    ? 'bg-blue-600 text-white'
    : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}

How do active and disabled states work?

The ternary expression checks if this button is the currently active action. If it is, the button turns solid blue (bg-blue-600 text-white). Otherwise it stays white with a subtle border.

disabled:opacity-50 greys out the button when the textarea is empty or a request is in progress. disabled:cursor-not-allowed changes the cursor to signal that clicking will not do anything.

Test the full app end-to-end

Now that you understand how the styling and feedback work together, it is time to run the app and test every feature with real writing samples.

  • Start the development server by running this command in your terminal:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see a clean, centered layout with "AI Writing Assistant" as the heading, a textarea on the left, a result panel on the right, and four action buttons below.

Page not loading?

Make sure you are inside the writing-assistant folder when you run the command. Check your terminal for any error messages about missing modules.

Help me debug why my Next.js dev server is not starting.

  • Paste the following sample text into the textarea:
I think that the meeting we had yesterday was pretty good and we discussed a lot of things that were important for the team to know about going forward in the future.
  • Click the Improve button.
  • Watch the result panel. You should see text streaming in word-by-word with a "Thinking..." animation appearing briefly before the first tokens arrive.
  • Notice the character count below both panels updating as text appears.
  • Click Copy to clipboard in the top-right of the result panel.
  • Paste into the textarea (Cmd+V on macOS, Ctrl+V on Windows) to confirm the copy worked.
  • Now click Concise to see the improved text shortened further.
  • Click Expand to see your text elaborated with more detail.
  • Click Formal Tone to see a professional rewrite.

What should I see?

Each action should produce noticeably different output. "Concise" should be shorter than the original. "Expand" should be longer. "Formal Tone" should read more professionally.

While streaming, the active button turns solid blue and shows "Processing..." as its label. The other buttons grey out so you cannot accidentally fire two requests at once.

  • Try one more test: clear the textarea completely and click any action button.
  • Confirm the buttons do nothing when the input is empty (they should appear greyed out).

Getting an error when you click an action button?

Check that your .env.local file contains a valid Gemini API key. The key should start with AI and have no extra spaces or quotes around it.

If you just added or changed the key, restart the dev server by pressing Ctrl+C in the terminal and running npm run dev again.

Help me fix my API route error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/page.js for comparison:

'use client';

import { useState } from 'react';

export default function Home() {
  const [inputText, setInputText] = useState('');
  const [outputText, setOutputText] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [activeAction, setActiveAction] = useState(null);

  const actions = [
    { id: 'improve', label: 'Improve', description: 'Make it clearer and more engaging' },
    { id: 'concise', label: 'Concise', description: 'Shorten without losing meaning' },
    { id: 'expand', label: 'Expand', description: 'Add more detail and examples' },
    { id: 'tone', label: 'Formal Tone', description: 'Make it professional' },
  ];

  async function handleAction(action) {
    if (!inputText.trim()) return;

    setActiveAction(action);
    setIsLoading(true);
    setOutputText('');

    const response = await fetch('/api/improve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: inputText, action }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let fullText = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (line.startsWith('0:')) {
          const text = JSON.parse(line.slice(2));
          fullText += text;
          setOutputText(fullText);
        }
      }
    }

    setIsLoading(false);
    setActiveAction(null);
  }

  function handleCopy() {
    navigator.clipboard.writeText(outputText);
  }

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">AI Writing Assistant</h1>
        <p className="text-gray-600 mb-8">
          Paste your text below and choose an action to transform it.
        </p>

        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <div className="space-y-4">
            <label className="block text-sm font-medium text-gray-700">Your Text</label>
            <textarea
              value={inputText}
              onChange={(e) => setInputText(e.target.value)}
              placeholder="Paste or type your text here..."
              className="w-full h-64 p-4 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <p className="text-sm text-gray-500">{inputText.length} characters</p>
          </div>

          <div className="space-y-4">
            <div className="flex items-center justify-between">
              <label className="block text-sm font-medium text-gray-700">Result</label>
              {outputText && (
                <button
                  onClick={handleCopy}
                  className="text-sm text-blue-600 hover:text-blue-800"
                >
                  Copy to clipboard
                </button>
              )}
            </div>
            <div className="w-full h-64 p-4 border border-gray-300 rounded-lg bg-white overflow-y-auto">
              {isLoading && !outputText && (
                <p className="text-gray-400 animate-pulse">Thinking...</p>
              )}
              {outputText && (
                <p className="text-gray-800 whitespace-pre-wrap">{outputText}</p>
              )}
              {!isLoading && !outputText && (
                <p className="text-gray-400">
                  Your improved text will appear here...
                </p>
              )}
            </div>
            <p className="text-sm text-gray-500">{outputText.length} characters</p>
          </div>
        </div>

        <div className="mt-8">
          <p className="text-sm font-medium text-gray-700 mb-3">Choose an action:</p>
          <div className="flex flex-wrap gap-3">
            {actions.map((action) => (
              <button
                key={action.id}
                onClick={() => handleAction(action.id)}
                disabled={isLoading || !inputText.trim()}
                className={`px-4 py-2 rounded-lg font-medium transition-colors ${
                  activeAction === action.id
                    ? 'bg-blue-600 text-white'
                    : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
                } disabled:opacity-50 disabled:cursor-not-allowed`}
                title={action.description}
              >
                {activeAction === action.id ? 'Processing...' : action.label}
              </button>
            ))}
          </div>
        </div>
      </div>
    </main>
  );
}

Your AI writing assistant is fully styled and tested. Every feature you built, from streaming responses to copy-to-clipboard, works together to create a professional tool. Next up, take on the Secret Mission and add a custom prompt builder.

Secret mission

Custom Prompt Builder

Your writing assistant has four fixed actions. But what if you want Gemini to rewrite your text like a pirate, translate it to Spanish, or turn it into a haiku? In this secret mission, you'll add a custom prompt input that lets users type any instruction they want.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • writing-assistant/ project folder on your Desktop (contains all source code, node_modules, and your .env.local file).
  • Node.js (installed in Step 1).

Keep everything running

No action needed. Choose this if you plan to keep building on this project or want to reference the code later.

  • Your project folder stays on your Desktop, ready for future edits.
  • The Gemini API free tier has no charges, so your API key can stay active.
  • Node.js remains available for any JavaScript or Next.js project you start in the future.

Pause - I'll come back to this later

Stop the development server to free up your terminal. Your files stay on disk.

  • If your dev server is still running, press Ctrl+C in the terminal to stop it.
  • Your project folder and all code remain on your Desktop for when you return.
  • To resume later, open the writing-assistant folder in VS Code and run npm run dev in the terminal.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • If your dev server is still running, press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/writing-assistant

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\writing-assistant
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see writing-assistant in the output.

What about Node.js?

Node.js is a general-purpose tool useful for many JavaScript projects. You probably want to keep it installed. If you are sure you do not need it, you can remove it through your system's application manager (macOS: Homebrew or the Node.js installer, Windows: Apps & features in Settings).

Nice Work!

Nice Work!

Nice work! You just built a fully functional AI writing assistant that transforms text in real-time using Next.js and Google Gemini.

You've learned how to:

  • Built a complete AI writing assistant from scratch, including an API route that streams responses from Google Gemini using the Vercel AI SDK.
  • Learned core React fundamentals: components, state management, event handling, and conditional rendering.
  • Styled a professional writing tool UI with Tailwind CSS, including real-time streaming display, character counts, and copy-to-clipboard functionality.
  • Secret Mission: Added a custom prompt builder that lets users write their own instructions for unlimited writing transformations.

Ready to quiz yourself?