Home / Blog / RAG with Nuxt and Gemini File Search
RAG with Nuxt and Gemini File Search

RAG with Nuxt and Gemini File Search

Daniel Kelly
Daniel Kelly
Updated: March 26th 2026

RAG (Retrieval-Augmented Generation) is one of the most practical ways to make AI apps useful in the real world.

Instead of asking a model to answer from generic training data, you:

  1. Index your own documents.
  2. Retrieve the most relevant portions of those documents (called "chunks") at query time.
  3. Generate an answer grounded in those chunks.

In this tutorial, you will build a working Nuxt backend that does exactly that using Google's robust but easy-to-implement RAG solution: Gemini File Search. I'll also provide the frontend UI so you can test the flow end to end.

What we are building

  • Server side utility functions for interacting with the Gemini File Search API.
  • Server side utility functions for managing the indexing and asking processes.
  • A Nuxt server endpoint that creates a File Search store.
  • A Nuxt server endpoint that uploads and indexes documents (text from a textarea input) into a Gemini File Search store.
  • A polling endpoint that checks the status of the indexing operation.
  • Another endpoint that queries the store with the File Search tool to answer a question.

To best showcase the RAG pipeline in a practical way, I'll also provide you with a simple UI to test things out. This includes:

  1. An input field to name your File Search store (auto-generated the first time).
  2. A text area to provide plain text documents to upload and index into the store.
  3. An input field to ask a question about any of the indexed documents
  4. Status updates for the indexing and asking processes.
  5. A section to display the answer grounded in the indexed text.
screenshot of the demo app

I'll also provide you with a page to manage the indexed documents in the store (view and delete them).

screenshot of the document management page

You can download and run the completed demo app from the GitHub repo.

Definition of Relevant RAG Terms

Throughout this guide, we will use the following terms related to RAG and the Gemini File Search API. Make sure you understand them before continuing.

  • RAG: Retrieval-Augmented Generation is a technique that uses a large language model to answer questions by retrieving relevant context from a knowledge base.
  • File Search: File Search is a Gemini API that allows you to index and search through your own documents. (aka. a batteries-included RAG pipeline)
  • Chunk: A chunk is a portion of a document that is indexed by the File Search API. Documents are split into these chunks so that the model can retrieve only the most relevant context when answering questions.
  • Store: A store is a collection of documents that are indexed by the File Search API. You can query 1 store at a time. Useful for organizing your documents into logical groups.
  • Tool: A tool is a function that can be called by a language model to perform a task. (in this case, the File Search tool)

Prerequisites

Step 1) Create a Nuxt App

Create a new Nuxt app with the minimal template:

npm create nuxt@latest nuxt-rag-app -- -t minimal

Then work from your app root:

cd nuxt-rag-app

All paths in the rest of this guide are relative to that project root.

Step 2) Install dependencies (Gemini SDK)

ni @google/genai @nuxtjs/mdc
  • @google/genai is the Google Gemini API SDK.

Alternately you could use the AI SDK as we discuss in our course AI Interfaces with Vue, Nuxt, and the AI SDK. It would make streaming the model output to the frontend a piece of cake. Plus it has other benefits but we're going to keep it simple for this tutorial and forego streaming.

Step 3) Expose Your API Key via Nuxt Runtime Config

Create a .env file in the root of your project and add your API key:

NUXT_GOOGLE_GENERATIVE_AI_API_KEY=your-api-key

Then update nuxt.config.ts to expose it via runtime config:

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: "2025-07-15",
  devtools: { enabled: true },
  modules: ["@nuxtjs/mdc"],
  runtimeConfig: {
    // this variable name must match the name in the .env file (converted to camelCase and without the NUXT_ prefix)
    googleGenerativeAiApiKey: "",
  },
});

Step 4) Add File Search Helper Functions

Before we add API endpoints, let's create a set of helper functions in server/utils/gemini-file-search.ts to manage the File Search operations with Gemini.

1. Import dependencies

First, import the required modules.

// server/utils/gemini-file-search.ts
import { GoogleGenAI } from "@google/genai";

2. Helper function to create an authenticated API client

Then create a function that initializes the Gemini API client with your API key and handles missing API key errors.

// server/utils/gemini-file-search.ts
function getApiKey() {
  const runtimeConfig = useRuntimeConfig();
  const apiKey = runtimeConfig.googleGenerativeAiApiKey;

  if (!apiKey) {
    throw createError({
      statusCode: 500,
      statusMessage: "Missing NUXT_GOOGLE_GENERATIVE_AI_API_KEY .env variable.",
    });
  }

  return apiKey;
}

function getClient() {
  return new GoogleGenAI({ apiKey: getApiKey() });
}

3. Create a File Search store

Next, provide a helper function for creating a File Search store.

// server/utils/gemini-file-search.ts
export async function createFileSearchStore(params: { displayName: string }) {
  const ai = getClient();
  const store = await ai.fileSearchStores.create({
    config: {
      displayName: params.displayName,
    },
  });

  return store.name ?? "";
}

4. Wait for an asynchronous operation from the API to complete

Gemini File Search indexing happens asynchronously. This function polls the operation status until it's done.

async function waitForOperation(ai: GoogleGenAI, operation: any) {
  let current = operation;
  while (!current.done) {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    current = await ai.operations.get({ operation: current });
  }
  return current;
}

5. Upload and index a text document in the store

This function uploads your text directly from memory as a Blob, then waits for indexing to finish.

export async function uploadTextToStore(params: {
  fileSearchStoreName: string;
  content: string;
  displayName: string;
}) {
  const ai = getClient();
  const markdownBlob = new Blob([params.content], { type: "text/markdown" });
  const operation = await ai.fileSearchStores.uploadToFileSearchStore({
    file: markdownBlob,
    fileSearchStoreName: params.fileSearchStoreName,
    config: {
      displayName: params.displayName,
      mimeType: "text/markdown",
    },
  });

  await waitForOperation(ai, operation);
}

6. Ask questions using the indexed File Search context

This function sends a question to Gemini, instructing it to answer only using retrieved context from your uploaded files. If Gemini can't answer with the provided context, it will say so.

Most importantly, note the usage of the fileSearch tool that gives Gemini access to the indexed documents!

export async function askStore(params: {
  fileSearchStoreName: string;
  question: string;
}) {
  const ai = getClient();
  const groundedQuestion = [
    "Answer using only the retrieved File Search context.",
    'If the context does not contain the answer, say: "I do not know based on the uploaded documents."',
    `Question: ${params.question}`,
  ].join("\n");

  const response = await ai.models.generateContent({
    model: "gemini-3-flash-preview",
    contents: groundedQuestion,
    config: {
      // 👇 this is the key part that gives Gemini access to the indexed documents!
      tools: [
        {
          fileSearch: {
            fileSearchStoreNames: [params.fileSearchStoreName],
          },
        },
      ],
    },
  });

  return {
    text: response.text ?? "",
    groundingMetadata: response.candidates?.[0]?.groundingMetadata ?? null,
  };
}

Step 5) Add display-name helper functions

After creating the File Search helpers, add one small utility file for generating store and document names.

Create server/utils/rag-display-names.ts:

// used if no store name is provided
export function createStoreDisplayName() {
  return `nuxt-rag-store-${Date.now()}`;
}

// since we're using a text input instead of an actual document with a filename, we need to generate a unique name for the document we import
// this does that based on the first non-empty line of the content
export function createDisplayNameFromContent(content: string) {
  const firstNonEmptyLine = content
    .split("\n")
    .map((line) => line.trim())
    .find((line) => line.length > 0);

  const base = (firstNonEmptyLine || "notes")
    .replace(/^#+\s*/, "")
    .replace(/[^a-zA-Z0-9\s-]/g, "")
    .trim()
    .replace(/\s+/g, "-")
    .toLowerCase()
    .slice(0, 48);

  return `${base || "notes"}-${Date.now()}`;
}

Step 6) Add storage and indexing-status helper functions

Indexing is asynchronous, so for a better UX we will:

  1. create an index job immediately,
  2. update job status in KV storage (useStorage()), and
  3. poll job status from the frontend.

Let's create some helper functions, variables, and types to help manage this process.

1. Define Types and Constants

We'll start by defining the job status type, the job object shape, and a key prefix for our storage.

// server/utils/rag-index-jobs.ts
import { randomUUID } from "node:crypto";

// Possible statuses for an indexing job
export type RAGIndexJobStatus = "pending" | "succeeded" | "failed";

// The job object structure
export type RAGIndexJob = {
  id: string;
  status: RAGIndexJobStatus;
  fileSearchStoreName: string;
  displayName: string;
  createdAt: string;
  updatedAt: string;
  errorMessage?: string; // Populated if failed
};

// Prefix for storing job entries in KV
export const INDEX_JOB_PREFIX = "rag:index-job:";

2. Helpers for Job Storage Keys

Next, we need a function to generate a unique storage key for each job based on its ID.

export function getJobKey(jobId: string) {
  return `${INDEX_JOB_PREFIX}${jobId}`;
}

3. Creating a New Index Job

When we start an indexing operation, we want to create a new job entry in our KV storage with a unique ID and an initial status of "pending".

export async function createIndexJob(params: {
  fileSearchStoreName: string;
  displayName: string;
}) {
  const job: RAGIndexJob = {
    id: randomUUID(),
    status: "pending",
    fileSearchStoreName: params.fileSearchStoreName,
    displayName: params.displayName,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  await useStorage().setItem(getJobKey(job.id), job);
  return job;
}

4. Retrieving a Saved Index Job

This function allows you to fetch job details from storage by job ID.

export async function getIndexJob(jobId: string) {
  return await useStorage().getItem<RAGIndexJob>(getJobKey(jobId));
}

5. Marking a Job as Succeeded

When an indexing operation completes successfully, use this to update its status.

export async function markIndexJobSucceeded(jobId: string) {
  const existing = await getIndexJob(jobId);
  if (!existing) return;

  await useStorage().setItem(getJobKey(jobId), {
    ...existing,
    status: "succeeded",
    updatedAt: new Date().toISOString(),
    errorMessage: undefined,
  });
}

6. Marking a Job as Failed

If indexing fails, call this to record the failure and the error message.

export async function markIndexJobFailed(params: {
  jobId: string;
  errorMessage: string;
}) {
  const existing = await getIndexJob(params.jobId);
  if (!existing) return;

  await useStorage().setItem(getJobKey(params.jobId), {
    ...existing,
    status: "failed",
    updatedAt: new Date().toISOString(),
    errorMessage: params.errorMessage,
  });
}

With these helpers in place, you can create, update, check, and manage the lifecycle of document indexing jobs in your app.

Step 7) Add API endpoints for stores, indexing, and asking questions

Now the API layer can focus on request validation and orchestration while reusing helper functions from server/utils.

Let's see how to create the API endpoints for creating stores, indexing documents, and asking questions.

1. Create a store API endpoint

Create server/api/rag/store.post.ts with the following code to support creating a store.

export default defineEventHandler(async (event) => {
  // get the name of the store from the body
  const body = await readBody<{ displayName?: string }>(event);
  const displayName = body.displayName?.trim() || createStoreDisplayName();

  // create the store with the helper function
  const fileSearchStoreName = await createFileSearchStore({
    displayName,
  });

  // if the store creation failed, throw an error
  if (!fileSearchStoreName) {
    throw createError({
      statusCode: 500,
      statusMessage: "Failed to create a File Search store.",
    });
  }

  // return the name of the store
  return {
    fileSearchStoreName,
  };
});

2. Create an indexing API endpoint

Create server/api/rag/index.ts with the following code to support indexing a document.

export default defineEventHandler(async (event) => {
  const body = await readBody<{
    content?: string;
    displayName?: string;
    storeName?: string;
  }>(event);

  // get the content of the document to index
  // and the store to index it into
  // from the request body
  const content = body.content?.trim();
  const fileSearchStoreName = body.storeName?.trim();

  // if the content is not provided, throw an error
  if (!content) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Request body needs a non-empty "content" field.',
    });
  }

  // if the store name is not provided, throw an error
  if (!fileSearchStoreName) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Request body needs a non-empty "storeName" field.',
    });
  }

  // if the display name for the document is not provided, create one from the content
  const displayName = body.displayName || createDisplayNameFromContent(content);

  // and then initialize the indexing job in KV storage
  const job = await createIndexJob({
    fileSearchStoreName,
    displayName,
  });

  // create a function to bundle
  // - doing the indexing
  // - and update the indexing status in KV storage
  const runIndexing = async () => {
    try {
      await uploadTextToStore({
        fileSearchStoreName,
        content,
        displayName,
      });
      await markIndexJobSucceeded(job.id);
    } catch (error: any) {
      await markIndexJobFailed({
        jobId: job.id,
        errorMessage:
          error?.data?.statusMessage ||
          error?.message ||
          "Unknown indexing error",
      });
    }
  };

  // Do the indexing in the background
  event.waitUntil(runIndexing());

  // and return the job id and status immediately with a 202 status code
  setResponseStatus(event, 202);
  return {
    ok: true,
    accepted: true,
    jobId: job.id,
    jobStatus: "pending",
    fileSearchStoreName,
  };
});

3. Create an index-status API endpoint

With the indexing API endpoint in place, we can kick off the indexing process but we don't yet have a way to check the status of the indexing job. Let's create an endpoint to do that.

Create server/api/rag/index-status.get.ts:

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const jobId = String(query.jobId || "").trim();

  // require the job id to be provided
  // we can't check the status of a job if we don't know the job id 🤪
  if (!jobId) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Query string needs a non-empty "jobId" value.',
    });
  }

  // get the job from the KV storage
  const job = await getIndexJob(jobId);

  // if the job is not found, throw an error
  if (!job) {
    throw createError({
      statusCode: 404,
      statusMessage: "Index job not found.",
    });
  }

  // return the job from the KV storage
  return { job };
});

Great work. You now have the backend flow for indexing documents. Easier than you thought, right?

4. Create an ask API endpoint

What's a document index without a way to ask questions about it? Now let's create an endpoint to do that.

Create server/api/rag/ask.post.ts:

export default defineEventHandler(async (event) => {
  // get the question from the request body
  // and the store of documents to ask the question about
  const body = await readBody<{ question?: string; storeName?: string }>(event);
  const question = body.question?.trim();
  const fileSearchStoreName = body.storeName?.trim();

  // if the question is not provided, throw an error
  if (!question) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Request body needs a non-empty "question" field.',
    });
  }

  // if the store name is not provided, throw an error
  if (!fileSearchStoreName) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Request body needs a non-empty "storeName" field.',
    });
  }

  // use the askStore helper function to ask the question of the File Search store
  // You could stream this response, but for simplicity we are not doing that here.
  const result = await askStore({ fileSearchStoreName, question });

  // The groundingChunks from the File Search API return the context used to answer the question.
  // we need to map that to the title, text, and fileSearchStore of the document that was used
  // so we can display the attributions in the UI
  const attributions = (result.groundingMetadata?.groundingChunks ?? [])
    .map((chunk: any) => chunk?.retrievedContext)
    .filter(Boolean)
    .map((ctx: any) => ({
      title: ctx.title ?? "Untitled document",
      text: ctx.text ?? "",
      fileSearchStore: ctx.fileSearchStore ?? fileSearchStoreName,
    }));

  // that's it!
  return {
    answer: result.text,
    attributions,
    groundingMetadata: result.groundingMetadata,
    fileSearchStoreName,
  };
});

Step 8) Hook up the UI

Since this tutorial focuses on the Nuxt backend and Gemini File Search API, you can find the full frontend code in the GitHub repo.

Bonus) Add documents management API endpoints

The Gemini File Search API also allows you to list and delete documents from a store. While not strictly necessary for our simple app, it's a good way to showcase the full capabilities of the API. And, of course, you'll likely need to list documents or remove a document from a store at some point in your own apps!

Let's add those endpoints to the backend.

1) Add document listing and delete helpers

Extend server/utils/gemini-file-search.ts with:

// server/utils/gemini-file-search.ts
export async function listStoreDocuments(params: {
  fileSearchStoreName: string;
}) {
  const ai = getClient();
  const result: Array<{ name: string; displayName: string }> = [];
  const documents = await ai.fileSearchStores.documents.list({
    parent: params.fileSearchStoreName,
  });

  for await (const document of documents as any) {
    result.push({
      name: document.name ?? "",
      displayName: document.displayName ?? document.name ?? "Untitled document",
    });
  }

  return result;
}

export async function deleteStoreDocument(params: { documentName: string }) {
  const ai = getClient();
  await ai.fileSearchStores.documents.delete({
    name: params.documentName,
    config: { force: true },
  });
}

2) Add API endpoints

Create server/api/rag/documents.get.ts:

// server/api/rag/documents.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const fileSearchStoreName = String(query.storeName || "").trim();

  if (!fileSearchStoreName) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Query string needs a non-empty "storeName" value.',
    });
  }

  const documents = await listStoreDocuments({ fileSearchStoreName });
  return { fileSearchStoreName, documents };
});

Create server/api/rag/documents.delete.ts:

// server/api/rag/documents.delete.ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ documentName?: string }>(event);
  const documentName = body.documentName?.trim();
  if (!documentName)
    throw createError({
      statusCode: 400,
      statusMessage: "Missing documentName",
    });

  await deleteStoreDocument({ documentName });
  return { ok: true };
});

And that's it! You now have a backend API for managing your File Search stores, indexing documents, and asking questions.

What we've built:

Here are the API endpoints provided in this tutorial:

1. List Documents in a File Search Store

Endpoint: GET /api/rag/documents?storeName=fileSearchStores/your-store-name

Returns the list of documents stored in the given File Search store.

Example Request:

curl "http://localhost:4310/api/rag/documents?storeName=fileSearchStores/your-store-name"

Example Response:

{
  "fileSearchStoreName": "fileSearchStores/your-store-name",
  "documents": [
    {
      "name": "fileSearchStores/your-store-name/documents/doc-1",
      "displayName": "My Document"
    }
  ]
}

2. Delete a Document from a Store

Endpoint: DELETE /api/rag/documents

Send a JSON body with the document's name to delete it from the store.

Example Request:

curl -X DELETE "http://localhost:4310/api/rag/documents" \
  -H "content-type: application/json" \
  -d '{"documentName":"fileSearchStores/your-store-name/documents/doc-1"}'

Example Response:

{
  "ok": true
}

3. Create a File Search Store

Endpoint: POST /api/rag/store

Creates a new File Search store that will hold your indexed documents.

Example Request:

curl -s -X POST "http://localhost:4310/api/rag/store" \
  -H "content-type: application/json" \
  -d '{"displayName":"smoke-test-store"}'

Example Response:

{
  "fileSearchStoreName": "fileSearchStores/abc123"
}

4. Upload and Index a Document

Endpoint: POST /api/rag

Uploads text content (as plain text) and indexes it into the specified File Search store.

Example Request:

curl -s -X POST "http://localhost:4310/api/rag" \
  -H "content-type: application/json" \
  -d '{"storeName":"fileSearchStores/your-store-name","content":"RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.","displayName":"smoke-test-notes"}'

Example Response:

{
  "ok": true,
  "accepted": true,
  "jobId": "3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6",
  "jobStatus": "pending",
  "fileSearchStoreName": "fileSearchStores/your-store-name"
}

5. Poll for Indexing Status

Endpoint: GET /api/rag/index-status?jobId=...

Checks the status of a long-running indexing operation.

Example Request:

curl -s "http://localhost:4310/api/rag/index-status?jobId=3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6"

Example Response:

{
  "job": {
    "id": "3f2d41a9-1f2d-43c2-9e21-7f4ce0d3a9b6",
    "status": "succeeded",
    "fileSearchStoreName": "fileSearchStores/your-store-name",
    "displayName": "smoke-test-notes"
  }
}

6. Ask Questions Grounded In Your Indexed Docs

Endpoint: POST /api/rag/ask

Sends a natural language question to the backend and gets a grounded answer based on your previously indexed documents.

Example Request:

curl -s -X POST "http://localhost:4310/api/rag/ask" \
  -H "content-type: application/json" \
  -d '{"storeName":"fileSearchStores/your-store-name","question":"What does RAG mean?"}'

Example Response:

{
  "answer": "RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.",
  "attributions": [
    {
      "title": "smoke-test-notes",
      "text": "RAG means Retrieval-Augmented Generation. It retrieves relevant context from your private docs before generation.",
      "fileSearchStore": "fileSearchStores/your-store-name"
    }
  ],
  "groundingMetadata": {
    /* ... */
  },
  "fileSearchStoreName": "fileSearchStores/your-store-name"
}

Wrapping up

If you enjoyed this article you might also be interested in the comprehensive RAG course over on aidd.io. It goes deep into detail about how RAG works and shows you step by step how to build every part of the process from scratch: from gathering documents, to chunking them, to generating embeddings, to indexing them, and more!

You'll also probably want to check out the official docs for Gemini File Search.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Learn how to use Pretext in Vue to measure multiline text without hidden DOM probes, forced reflow, or brittle getBoundingClientRect loops.
Daniel Kelly
Daniel Kelly
Generating Random IDs in Vue.js

Generating Random IDs in Vue.js

How Vue 3.5’s useId() composable gives you stable, unique DOM IDs for forms and accessibility—without manual counters or hydration bugs.
Daniel Kelly
Daniel Kelly
VueSchool logo

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.