The journey begins

The current AI landscape can feel overwhelming, especially for frontend developers. New models appear constantly, tutorials often double as marketing, and experienced practitioners guard their knowledge behind jargon like "agentic AI" and "RAG". If you're an Angular developer, you might wonder whether you need to learn Python or become a backend specialist just to add intelligence to your apps.

After spending several months working with the Gemini API across various projects, I'm here to tell you that this doesn't have to be the case. This series will guide Angular developers through building AI-powered applications, step by step, without assuming any prior AI knowledge. We'll break everything down into manageable pieces, starting from absolute zero.

What we'll cover

Throughout this series, we'll explore a wide range of topics that go far beyond simple text generation:

  1. Getting started with Gemini API: API access, making requests, building chat experiences, and understanding key configuration options.
  2. Using embeddings: Unlocking capabilities beyond text generation through vector representations.
  3. Building RAG applications: Implementing retrieval augmented generation with Gemini and an Angular frontend.
  4. Using multimodal capabilities: Working with images, audio, and video inputs.
  5. Building agentic AI apps: Creating applications that can reason, plan, and execute tasks autonomously.
  6. Slightly touching machine learning: Building reliable, task-specific AI tools without relying on LLMs.
  7. A lot more!

Don't expect exactly seven articles — I may break topics into smaller, digestible chunks, so the series could expand significantly.

Let's dive into our first topic!

Getting started with Gemini API

Before we write any code, let's clarify how developers actually interact with LLMs. If you assumed they merely make API calls to providers like OpenAI or Gemini, you'd be correct. But there's something even better: Gemini offers a dedicated SDK that simplifies API interactions considerably. Let's begin by generating a new Angular app and installing the Gemini SDK inside it.

npm i @google/genai

This command installs the Google Generative AI SDK, which provides a cleaner alternative to making raw fetch requests to the API.

Now, we need to address the first major hurdles newcomers typically face: obtaining an API token and configuring billing. Many developers get intimidated at this stage, worried about unexpected costs or complex setups. The good news is that Google provides both a generous free tier and several inexpensive models, making experimentation affordable. Here's what we need to do:

  1. Navigate to the Google Cloud Console and authenticate with your Google account.
  2. Create a new project and give it a memorable name.
  3. Visit Google AI Studio at https://studio.google.cloud.com/.
  4. Locate the "Get API key" option in the left sidebar, which will take you to this page.
  5. Generate a new API key and associate it with the project created in step 2.
  6. Store the API key securely — we'll need it shortly.

With the API key in hand, it might be tempting to create an Angular service, instantiate the API client, and start making requests directly. Please resist this temptation! Placing your API key in frontend code exposes it to anyone who opens the browser devtools, creating both security and cost vulnerabilities. Instead, we'll build a compact backend that handles AI interactions, with an Angular service acting as the bridge to our backend. This approach keeps the API key protected and allows us to implement additional features like caching and rate limiting.

Don't worry — we're building a thin wrapper, not a complex backend infrastructure. We'll use Express.js for this purpose. If you're already familiar with Express, feel free to skip ahead to the final code example in this section. Otherwise, read on!

Building a small Express.js backend

Express.js is a lightweight Node.js framework for building backend applications. To get started, we'll install Express within our existing Angular project:

npm install express cors

Next, create a file named server.js in the project root and add the following code:

const express = require('express'); // importing express
const app = express();
const cors = require('cors'); // to handle CORS

app.use(cors()); // enable CORS
app.use(express.json()); // to parse JSON bodies

app.get('/', (req, res) => {
  res.send('Hello!');
}); 

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

After this, visiting http://localhost:3000 in your browser should display "Hello!".

Let's recap what we just did: we created an Express app, defined a route that returns "Hello!" when accessed, either via browser or an API call like fetch, and configured the app to listen on port 3000.

Now we need to create an endpoint that interacts with the Gemini API. But first, we need to decide where to put our API key, since embedding it directly in source code is risky — imagine pushing it to a public GitHub repository. The standard solution is using environment variables, a concept you might already be comfortable with, even as an Angular-only developer.

In Node.js, a popular approach involves .env files and the dotenv package. Let's install it:

npm install dotenv

Then, create a file named .env in the project root and add this line:

GEMINI_API_KEY=your_api_key_here

Now, let's modify server.js to load environment variables from the .env file:

const express = require('express'); 
require('dotenv').config(); // load environment variables from .env file
// rest of the code stays the same for now

To see everything in action, let's update our "Hello!" endpoint to generate actual content using Gemini. We'll import the GenAI SDK, create an API client instance, and use it for text generation:

const genAI = new GoogleGenAI({});

app.get('/', async (req, res) => {

  const response = await genAI.models.generateContent({
    model: 'gemini-1.5-pro', // specify the model to use
    contents: 'Give me a random greeting',
  });

  res.json(response); // return the response as JSON
});

This example is quite straightforward: we instantiate GoogleGenAI, and within our endpoint, we call the generateContent method, specifying the model we want — here, gemini-1.5-pro, which handles most tasks effectively — along with the content to generate. The API response is returned as JSON.

You might have noticed we didn't explicitly provide an API key anywhere in the code. The GenAI SDK automatically reads the GEMINI_API_KEY environment variable we defined. This convenient feature keeps your key completely out of the source code.

Let's visit http://localhost:3000 again to examine the response, which might resemble this:

app.use(express.json()); // to parse JSON bodies
{
  "sdkHttpResponse": {
    "headers": {
      <A lot of header here>
    }
  },
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "Howdy!\n"
          }
        ],
        "role": "model"
      },
      "finishReason": "STOP",
      "avgLogprobs": -0.0416780412197113
    }
  ],
  "modelVersion": "gemini-1.5-pro-002",
  "usageMetadata": {
    "promptTokenCount": 5,
    "candidatesTokenCount": 3,
    "totalTokenCount": 8,
    "promptTokensDetails": [
      {
        "modality": "TEXT",
        "tokenCount": 5
      }
    ]
  }
}

The response contains additional fields, but we'll focus on the three key top-level properties:

  • sdkHttpResponse: The raw HTTP response from the API, including headers and status codes. This is invaluable for debugging errors and showing users appropriate messages.
  • candidates: The core of the response, holding the actual generated content. In this case, we asked for a random greeting, and the model responded with "Howdy!". The candidates array may contain multiple options, which becomes relevant when we explore streaming responses.
  • usageMetadata: Token usage information for the request, useful for cost monitoring and optimization. We'll explore this more later.

With our backend in place, we can now create an Angular service that communicates with our backend rather than hitting the Gemini API directly.

Creating an Angular service to interact with our backend

So far, we've only scratched the surface of the Gemini API. Let's advance by creating an endpoint that accepts user input and responds accordingly, rather than generating random greetings:

app.post('/generate', async (req, res) => {
  const { prompt } = req.body; // get the prompt from the request body

  if (!prompt) {
    return res.status(400).json({ error: 'Prompt is required' });
  }

  try {
    const response = await genAI.models.generateContent({
      model: 'gemini-1.5-pro',
      contents: prompt,
    });

    res.json(response);
  } catch (error) {
    console.error('Error generating content:', error);
    res.status(500).json({ error: 'Failed to generate content' });
  }
});

Although this involves slightly more code, the logic remains simple: we extract a prompt from the request body and use it to generate content via Gemini. Missing prompts return a 400 error, and generation failures return a 500 error — standard API behavior.

Now, let's build an Angular service to interact with this endpoint.

export type GeminiResponse = {
    candidates: {
        content: {
            parts: {
                text: string
            }[]
        }
    }[];
}

@Injectable({providedIn: 'root'})
export class GenAIService {
    readonly #http = inject(HttpClient);

    generateContent(prompt: string) {
        return this.#http.post<GeminiResponse>('http://localhost:3000/generate', {prompt}).pipe(
            // map the response to just return the generated text
            map(
                response => response.candidates[0]?.content.parts[0].text || 'No response',
            )
        )
    }
}

The GeminiResponse type looks intimidating, especially since we've omitted most fields to focus on the generated text. Don't let it scare you — keep this type handy, as 90% of the time, you'll only need the inner parts field containing the actual content.

Finally, let's create a component that uses this service and displays the generated content:

@Component({
  template: `
    <div class="container">
      <h2>AI Text Generator</h2>
      
      <form #textForm="ngForm" (ngSubmit)="generateResponse()" class="form">
        <div class="input-group">
          <label for="prompt">Enter your prompt:</label>
          <textarea 
            id="prompt"
            name="prompt"
            [(ngModel)]="prompt"
            required
            placeholder="Type your prompt here..."
            rows="4"
            class="textarea">
          </textarea>
        </div>
        
        <button 
          type="submit" 
          [disabled]="!textForm.form.valid"
          class="submit-btn">
          Generate Response
        </button>
      </form>
      @let response = generatedResponse();
      <div class="response-section">
          <h3>Response:</h3>
          <div 
              [class.response-box]="response.error === null"
              [class.error-box]="response.error !== null">
              {{ response.text }}
          </div>
      </div>
    </div>
  `,
})
export class GenerateTextComponent {
    readonly #genAI = inject(GenAIService);
    prompt = signal('');
    generatedResponse = signal<{text: string, error: string | null}>({
        text: '', 
        error: null,
    });

    generateResponse() {
        // I would be very very happy to do this via resources
        // but they do not yet support POST requests
        // P.S. read more about resources in my article: https://www.angularspace.com/meet-http-resource/
        this.#genAI.generateContent(this.prompt()).subscribe({
            next: (response) => this.generatedResponse.set({
                text: response, error: null,
            }),
            error: () => this.generatedResponse.set({
                text: '', error: 'Error generating text',
            })
        });
    }
  
}

As you can see, the frontend part remains fairly simple: we call our service method, wait for the HTTP request to complete, store the result in a signal, and render it. Nothing too complicated.

At this point, you might be excited to jump into more advanced features like chat functionality or streaming. However, I recommend taking a detour to explore some Gemini configuration options first — understanding these will prove invaluable as we progress.

Adjusting Gemini API Settings

Selecting the right model

Earlier, we chose gemini-1.5-pro as the model for our requests. It’s a robust option, but it’s worth remembering that different tasks can benefit from different models. Sometimes, a lighter or more specialized model is a better fit than a top-tier one.

To explore your options, you can set up an endpoint that returns the list of models available to you:

app.get('/models', async (req, res) => {
  try {
    const response = await genAI.models.list();
    res.json(response);
  } catch (error) {
    console.error('Error listing models:', error);
    res.status(500).json({ error: 'Failed to list models' });
  }
});

With that in place, you can build a component using an httpResource to display the available models:

@Component({
  template: `
    <div class="models-container">
      <h2>Available Models</h2>
      
      @if (modelsResource.isLoading()) {
        <div class="loading">Loading models...</div>
      }
      
      @if (modelsResource.error()) {
        <div class="error">Error loading models: {{ modelsResource.error() }}</div>
      }
      
      @if (modelsResource.value(); as models) {
        <ul class="models-list">
          @for (model of modelsResource.value()?.pageInternal; track model.name) {
            <li class="model-card">
              <h3>{{ model.displayName }}</h3>
              <p><strong>Name:</strong> {{ model.name }}</p>
            </li>
          }
        </ul>
      }
    </div>
  `,
})
export class ModelsListComponent {  
  modelsResource = httpResource<{pageInternal: {name: string, displayName: string}[]}>(
    () => 'http://localhost:3000/models'
);
}

Opening this component will reveal a long list—about 50 models—each designed for different purposes. Some excel at reasoning and complex instructions, others are optimized for fast, conversational replies, and still others are built for image or video generation. There are also embedding models, which we will cover in a future installment.

Picking the right model can take some experimentation. In general, you’ll want to balance these three considerations:

  1. Cost: More powerful models often come with a higher price tag.
  2. Speed: Models with deep reasoning capabilities tend to be slower, especially if thinking mode is enabled, though they can handle more complex tasks beyond simple text output.
  3. Task fit: You often don’t need the newest or most advanced model. A simpler one that performs well enough can save both money and time.

For detailed information on model capabilities and pricing, the official documentation is the best resource.

Now, let’s look at what actually determines the cost of using these models.

Understanding your costs

Let’s revisit our initial example and examine the usageMetadata field in the response:

{
  "usageMetadata": {
    "promptTokenCount": 5,
    "candidatesTokenCount": 3,
    "totalTokenCount": 8,
    "promptTokensDetails": [
      {
        "modality": "TEXT",
        "tokenCount": 5
      }
    ]
  }
}

This field provides a breakdown of token usage for the request. If you're not familiar with the concept of "tokens" in large language models, the next few paragraphs will clarify things. If you already know, feel free to skip ahead.

At a high level, an LLM generates text by predicting the next "token" in a sequence. A token can be a single word, a portion of a word, a punctuation mark, or a special character.

To get a visual sense of how tokenization works, you can use the OpenAI tokenizer tool. For instance, inputting the phrase "Hello, world!" breaks it down into four tokens: "Hello", ",", " world", and "!".

This process is fundamental to how LLMs operate. They take your input, break it into tokens, and then predict the next token based on the ones that came before. This is how they produce coherent, relevant text. It’s also worth noting that tokenization is fairly consistent—the same text will typically be split into the same tokens regardless of context.

Now that we understand tokens, we can look at how Gemini API pricing works. You are charged based on the total number of tokens processed during a request. This includes both the input tokens (your prompt) and the output tokens (the model's response). Looking back at the models list, you’ll see that each model has separate pricing for input and output tokens, with output tokens generally being more expensive.

While these figures might look significant, remember that they are for generating one million (!) tokens. For a small, learning-focused app, that's an enormous amount of text—roughly 750,000 words, which is more than double the length of the entire "Lord of the Rings" trilogy. For most prototyping and experimentation, your actual costs will be just a few cents, if anything, especially if you stay within the free tier.

With a handle on pricing, let’s look at some parameters you can use to shape the responses you get from the model before we wrap up the first part of this series.

Response parameters

Welcome to the part with all the buzzwords! Here, we’ll go over some of the most important options you have when working with LLMs. You might have heard of these terms before, even if you haven't fully understood them. We’re talking about temperature, topP, topK, and maxOutputTokens.

Here’s a quick summary:

  • temperature: This parameter controls the randomness of the model's output.

Previously, we said that LLMs predict the next token. That was a simplification. In reality, they calculate a probability for every possible token. For example, the model might determine there's a 30% chance the next token is cat, a 25% chance it's dog, a 15% chance it's fish, and so on. Crucially, models usually don't always pick the most probable token. If they did, their responses would be stiff and repetitive.

Instead, they often select from less probable but still contextually appropriate tokens. This is where temperature comes in. It adjusts the degree of randomness in token selection. A lower temperature (like 0.2) makes the model lean towards more probable tokens, resulting in more predictable output. A higher temperature (like 0.8) encourages picking less probable tokens, leading to more creative responses.

Note: temperature is not an exact science. You might hear that setting it to "0" makes the model "deterministic," but that's not quite accurate in the usual sense. A slight change in the prompt, such as a missing comma, can still lead to vastly different outputs. Temperature is a useful tool, but it's not a magic fix for all LLM output issues.

  • topK: This parameter imposes a hard limit on the token selection pool. While temperature influences which tokens are chosen from the entire set, topK simply restricts the set itself. For instance, setting topK to 3 means the model will only pick the next token from the 3 most probable options. This tool is somewhat blunt, and many people don't use it, as it's hard to know in advance whether the top 3 (or 7, or 12) tokens are the right ones or if a lower-ranked token would be better.

  • topP: This parameter is more nuanced and often more useful. Rather than limiting the *number* of tokens, it limits the *cumulative probability*. This means it adds up the probabilities of the most likely tokens until a certain threshold is met. For example, setting topP to 0.9 means the model will consider the most probable tokens until their combined probability reaches 90%. This allows the number of candidate tokens to fluctuate naturally based on their individual probabilities, making the selection process more dynamic.

  • maxOutputTokens: As the name suggests, this sets a maximum limit on the number of tokens the model can generate in one response. This can help prevent overly long and costly responses. For example, setting maxOutputTokens to 50 will stop the generation after 50 tokens are produced.

Important: The maxOutputTokens parameter is a blunt tool that simply cuts off generation. It does not make the model produce shorter, more concise text on its own. It should be seen as a safety net to prevent runaway generation and unexpected costs. To get shorter content, you're better off crafting prompts that guide the model towards brevity. Again, not an exact science, but it generally works.

All of these parameters are supported in the Gemini API SDK. Now, we can update our text-generation endpoint to accept and apply them:

const response = await genAI.models.generateContent({
    model: 'gemini-1.5-pro',
    contents: prompt,
    config: {
      topP: 0.5,
      temperature: 0.1,
      maxOutputTokens: 50,
    }
});

With these changes, retrying the same prompts from our Angular app's generate page should produce more consistent and structured responses. However, keep in mind that with such a low maxOutputTokens value, some messages may get cut off mid-sentence.

Wrapping Up

That was certainly a lot of ground to cover! Yet, it might feel like we've only touched the surface. And you'd be right. Let's do a quick recap:

  • We looked at the fundamentals of how LLMs generate text, covering concepts like tokens and configuration parameters such as temperature and topP.
  • We walked through setting up a Google Cloud project, generating a Gemini API key, and using the SDK for text generation.
  • We became familiar with the different models available for future, more specialized tasks.
  • We accomplished all of this without requiring knowledge beyond that of a typical Angular developer.

If this has piqued your interest, you'll be pleased with what's next. In the upcoming article, we will:

  • Implement response streaming to create a smoother user experience.
  • Build chat functionality and manage conversation context across messages.
  • Dive into prompt engineering to get higher-quality responses.
  • Explore structured outputs that allow for solving more concrete tasks beyond pure text generation.

Thanks for reading, and see you in the next one!

Plug

Gg2RPJKWwAAHSId.png
My book, Modern Angular, is now available in print! I put a lot of effort into writing about every new feature Angular introduced from v12 to v18, including enhanced dependency injection, RxJS interop, Signals, SSR, Zoneless, and much more.

If you're working on a legacy project, this book should help you catch up on all the new and exciting things our favorite framework has to offer. You can check it out here: https://www.manning.com/books/modern-angular

P.S. There's a chapter in the book about integrating LLMs in Angular applications, and it’s already a bit dated, even though the book was only published earlier this year. It's a testament to how fast the AI field is evolving! I hope you can forgive me ;)


Building AI-powered apps with Angular and Gemini — figure 2

Building AI-powered apps with Angular and Gemini — figure 3

Tagged in:

Articles, AI

Last Update: September 29, 2025