Volver
Desarrollo WebHerramientas

Cómo crear una extensión de Chrome desde cero con Manifest V3

Una extensión de Chrome no es una web normal

Una extensión de Chrome es una pequeña aplicación que vive dentro del navegador. Puede añadir un popup a la barra de herramientas, modificar páginas, escuchar eventos del navegador o guardar datos locales.

La diferencia con una web normal está en los permisos. Una extensión puede leer la pestaña activa, almacenar configuraciones o interactuar con una URL. Chrome no regala ese acceso. Hay que declararlo antes en un archivo llamado manifest.json.

Durante los últimos días he estado trabajando en mi propia extensión. Todavía no puedo enseñarla completa porque quiero cerrar algunos flujos antes de compartirla, pero me ha servido para volver a una conclusión bastante clara: crear una extensión pequeña es más fácil de lo que parece. Lo complicado empieza cuando quieres que no sea un cajón de botones con ansiedad.

Para explicar la base voy a crear una extensión funcional llamada Read Later Tabs. Tendrá un popup, leerá la pestaña actual y permitirá guardarla en una lista local para revisarla después.

No necesita backend. No necesita React. No necesita instalar media galaxia de dependencias. Para empezar, prefiero eso.

La estructura mínima del proyecto

Creo una carpeta con estos archivos:

read-later-tabs/
├── manifest.json
├── popup.html
├── popup.css
└── popup.js

Chrome carga extensiones desde una carpeta local durante el desarrollo. No hay build obligatorio si uso HTML, CSS y JavaScript plano.

Mi recomendación para una primera extensión es esta: empieza sin framework. Si el producto crece, ya habrá tiempo de meter Vite, TypeScript, React o lo que toque. Añadir complejidad antes de tener una funcionalidad útil es una tradición web que intento evitar.

El manifest.json: el contrato con Chrome

El manifest es el archivo principal. Define el nombre de la extensión, su versión, los permisos y los archivos que Chrome debe cargar.

Desde 2023, lo correcto es usar Manifest V3. Manifest V2 está retirado en Chrome y seguir tutoriales antiguos suele acabar en errores bastante aburridos.

Creo este archivo:

{
  "manifest_version": 3,
  "name": "Read Later Tabs",
  "version": "1.0.0",
  "description": "Guarda la pestaña actual para leerla después.",
  "permissions": ["storage", "tabs"],
  "action": {
    "default_title": "Guardar para después",
    "default_popup": "popup.html"
  }
}

Cada campo cumple una función:

CampoPara qué sirve
manifest_versionIndica la versión del sistema de extensiones. Debe ser 3.
nameNombre visible en Chrome.
versionVersión de la extensión. Chrome la usa al actualizarla.
permissionsAPIs a las que quiero acceder.
actionConfigura el icono de la barra de herramientas y su popup.

Uso dos permisos:

  • storage permite guardar datos con chrome.storage.
  • tabs permite consultar el título y la URL de la pestaña activa.

Los permisos importan más de lo que parece. Chrome puede mostrarlos al instalar una extensión y los usuarios los revisan. Con razón. Si mi extensión solo guarda pestañas, pedir acceso a todas las webs del navegador sería una mala señal.

Prefiero aplicar una regla sencilla: pido el permiso mínimo que necesita cada funcionalidad.

Crear el popup de la extensión

El popup es la ventana pequeña que aparece al pulsar el icono de una extensión en la barra de Chrome.

No es una página especial. Es HTML normal, con sus límites. Tiene un tamaño reducido y se cierra cuando el usuario hace clic fuera. Eso condiciona bastante el diseño.

Creo popup.html:

<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Read Later Tabs</title>
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <main class="popup">
      <header>
        <h1>Leer después</h1>
        <p>Guarda la pestaña activa para revisarla luego.</p>
      </header>

      <button id="save-tab" type="button">Guardar pestaña actual</button>

      <section aria-labelledby="saved-tabs-title">
        <h2 id="saved-tabs-title">Guardadas</h2>
        <ul id="saved-tabs" class="tabs-list"></ul>
      </section>
    </main>

    <script src="popup.js"></script>
  </body>
</html>

Hay un detalle importante: no uso JavaScript inline.

Esto no funcionaría correctamente en una extensión moderna:

<button onclick="saveCurrentTab()">Guardar</button>

Chrome aplica una política de seguridad estricta llamada Content Security Policy. Entre otras cosas, bloquea scripts inline. La solución es cargar un archivo JavaScript externo, como hago con popup.js.

También evito cargar CSS o scripts desde un CDN. Las extensiones de Chrome deben empaquetar sus recursos localmente. Si necesito una librería, la incluyo dentro del proyecto.

Darle un estilo legible

Una extensión no necesita ganar un premio de diseño para ser útil, pero tampoco tiene que parecer una ventana de configuración de 2007.

Creo popup.css:

:root {
  font-family: Inter, system-ui, sans-serif;
  color: #e5e7eb;
  background: #111827;
}

body {
  width: 340px;
  margin: 0;
}

.popup {
  padding: 16px;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 4px;
  font-size: 18px;
}

h2 {
  margin: 20px 0 10px;
  font-size: 14px;
}

p {
  color: #9ca3af;
  font-size: 13px;
  line-height: 1.5;
}

Después añado los estilos del botón y de la lista:

button {
  width: 100%;
  border: 0;
  border-radius: 8px;
  padding: 10px 12px;
  color: #111827;
  background: #fbbf24;
  cursor: pointer;
  font-weight: 700;
}

button:hover {
  background: #fcd34d;
}

.tabs-list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.tab-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px;
  border-radius: 8px;
  background: #1f2937;
}

El popup tiene una anchura fija porque Chrome calcula su tamaño a partir del contenido. Si no limito el ancho, puedo acabar con una interfaz incómoda o con títulos largos rompiendo el layout.

Completo el CSS con el enlace y el botón para borrar elementos:

.tab-link {
  flex: 1;
  overflow: hidden;
  color: #e5e7eb;
  font-size: 13px;
  text-decoration: none;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.remove-tab {
  width: auto;
  padding: 4px 8px;
  color: #fca5a5;
  background: transparent;
  font-size: 12px;
}

.remove-tab:hover {
  background: #374151;
}

.empty-state {
  color: #9ca3af;
  font-size: 13px;
}

Leer la pestaña activa con la API de Chrome

Ahora llega la parte que convierte una página HTML en una extensión.

Chrome expone APIs globales a través del objeto chrome. En este caso uso chrome.tabs para obtener la pestaña activa y chrome.storage.local para guardar datos dentro del navegador.

Empiezo popup.js seleccionando los elementos del DOM:

const saveButton = document.querySelector("#save-tab");
const tabsList = document.querySelector("#saved-tabs");

saveButton.addEventListener("click", saveCurrentTab);

document.addEventListener("DOMContentLoaded", renderSavedTabs);

Cuando el usuario pulsa el botón, ejecuto saveCurrentTab. Esta función consulta la pestaña visible en la ventana actual:

async function getCurrentTab() {
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true
  });

  return tab;
}

chrome.tabs.query() devuelve un array. Aunque solo pido una pestaña activa, la API mantiene ese formato. Por eso extraigo el primer elemento con [tab].

Ahora guardo los datos que me interesan:

async function saveCurrentTab() {
  const tab = await getCurrentTab();

  if (!tab.url || !tab.title) {
    return;
  }

  const savedTabs = await getSavedTabs();

  const alreadySaved = savedTabs.some((savedTab) => savedTab.url === tab.url);

  if (alreadySaved) {
    return;
  }

  const nextTabs = [{ title: tab.title, url: tab.url }, ...savedTabs];

  await chrome.storage.local.set({ savedTabs: nextTabs });

  renderSavedTabs();
}

No guardo el objeto completo que devuelve Chrome. Tiene propiedades que no necesito y algunas pueden cambiar entre versiones del navegador.

Guardo solo title y url. Es un objeto pequeño, predecible y fácil de migrar si más adelante cambio el formato.

También evito duplicados comparando la URL. Si guardo cinco veces la misma pestaña, la extensión deja de ser una lista de lectura y se convierte en una lista de mis despistes.

Guardar datos con chrome.storage.local

chrome.storage.local funciona como un almacenamiento clave-valor. Se parece a localStorage, pero está pensado para extensiones y funciona correctamente entre sus distintas partes.

Creo una función para recuperar la lista guardada:

async function getSavedTabs() {
  const data = await chrome.storage.local.get("savedTabs");

  return data.savedTabs ?? [];
}

Uso ?? [] porque la primera vez no existirá ninguna clave llamada savedTabs. Sin ese fallback, mi código intentaría recorrer undefined y el popup moriría con discreción. Los errores de JavaScript tienen ese talento.

Hay otro almacenamiento llamado chrome.storage.sync. Ese sincroniza datos entre navegadores Chrome donde el usuario haya iniciado sesión.

Para una lista pequeña podría usarlo. Aun así, prefiero local al empezar. Tiene menos límites de cuota y no convierte una prueba local en un problema de sincronización.

Renderizar las pestañas guardadas

La extensión ya puede guardar información, pero todavía no muestra nada al abrir el popup. Para eso creo renderSavedTabs.

async function renderSavedTabs() {
  const savedTabs = await getSavedTabs();

  tabsList.innerHTML = "";

  if (savedTabs.length === 0) {
    tabsList.innerHTML = '<li class="empty-state">No has guardado ninguna pestaña.</li>';
    return;
  }

  savedTabs.forEach((tab) => {
    const item = createTabItem(tab);
    tabsList.append(item);
  });
}

Si no hay elementos, muestro un estado vacío. Es un detalle pequeño, pero evita que la interfaz parezca rota.

Para crear cada elemento de la lista uso el DOM en lugar de interpolar HTML con URLs externas:

function createTabItem(tab) {
  const item = document.createElement("li");
  const link = document.createElement("a");
  const removeButton = document.createElement("button");

  item.className = "tab-item";

  link.className = "tab-link";
  link.href = tab.url;
  link.target = "_blank";
  link.rel = "noreferrer";
  link.textContent = tab.title;

  removeButton.className = "remove-tab";
  removeButton.type = "button";
  removeButton.textContent = "Borrar";
  removeButton.addEventListener("click", () => removeTab(tab.url));

  item.append(link, removeButton);

  return item;
}

Uso textContent para insertar el título. No uso innerHTML con datos que vienen de una pestaña externa.

El título de una web puede contener caracteres inesperados o incluso HTML. textContent evita que ese contenido se interprete como código. Aunque sea una extensión local y pequeña, no me gusta normalizar hábitos inseguros.

Termino con la función para borrar pestañas:

async function removeTab(url) {
  const savedTabs = await getSavedTabs();

  const nextTabs = savedTabs.filter((tab) => tab.url !== url);

  await chrome.storage.local.set({ savedTabs: nextTabs });

  renderSavedTabs();
}

Con esto ya tengo el ciclo completo:

  1. Chrome abre el popup.
  2. El popup carga las pestañas guardadas.
  3. Pulso el botón y leo la pestaña activa.
  4. Guardo su título y URL.
  5. Actualizo la interfaz.
  6. Puedo abrir o borrar cualquier elemento.

Cargar la extensión en Chrome

Para probarla abro esta URL en Chrome:

chrome://extensions

Después sigo estos pasos:

  1. Activo el interruptor Modo de desarrollador.
  2. Pulso Cargar descomprimida.
  3. Selecciono la carpeta read-later-tabs.
  4. Fijo la extensión a la barra de herramientas con el icono de la pieza de puzzle.
  5. Abro una web y pulso el icono de la extensión.

Chrome mostrará la extensión con un icono genérico porque no he añadido iconos propios. No afecta a la funcionalidad.

Cada vez que cambio manifest.json, vuelvo a chrome://extensions y pulso el botón de recargar. Para cambios en HTML, CSS o JavaScript, también lo hago por costumbre. Después cierro y vuelvo a abrir el popup.

El popup no se actualiza solo mientras está abierto. Es normal. No es un bug misterioso ni una prueba de que Chrome me odia. Bueno, no esta vez.

Cuándo necesito un service worker

Esta extensión no usa un service worker porque todo ocurre mientras el popup está abierto.

Necesito uno cuando quiero ejecutar código sin abrir el popup. Por ejemplo:

  • Escuchar instalaciones o actualizaciones.
  • Crear menús contextuales.
  • Responder a eventos de navegación.
  • Ejecutar tareas en segundo plano.
  • Coordinar mensajes entre un popup y scripts inyectados en páginas.

En Manifest V3, el antiguo background page se sustituye por un service worker. Chrome lo inicia cuando hace falta y lo detiene cuando queda inactivo.

Una configuración mínima sería esta:

{
  "background": {
    "service_worker": "background.js"
  }
}

Y el archivo background.js podría escuchar la instalación:

chrome.runtime.onInstalled.addListener(() => {
  console.log("Read Later Tabs instalada");
});

No añado esto al ejemplo principal porque no aporta ninguna funcionalidad real todavía. Meter un service worker “por si acaso” es como instalar Kubernetes para una landing. Técnicamente posible. Razonable, no tanto.

Errores comunes al crear la primera extensión

El error más frecuente es usar tutoriales de Manifest V2. Si veo browser_action, background.page o manifest_version: 2, cierro esa pestaña y busco otra referencia.

También veo extensiones pidiendo permisos excesivos. "<all_urls>" permite actuar sobre cualquier página. Solo lo uso cuando necesito inyectar código en sitios arbitrarios. Para un popup que consulta la pestaña actual, no hace falta.

Otro fallo habitual es confiar en localStorage. Puede funcionar en ciertos contextos, pero chrome.storage es la API diseñada para extensiones. Tiene APIs asíncronas, es compartible entre componentes de la extensión y evita comportamientos confusos.

Por último, no conviene guardar secretos dentro de la extensión. Una API key incluida en JavaScript puede inspeccionarse. Si mi extensión necesita acceder a un servicio privado, prefiero un backend intermedio o autenticación basada en tokens de usuario.

El siguiente paso es construir algo que merezca quedarse instalado

La base de una extensión de Chrome cabe en cuatro archivos: un manifest, una interfaz, estilos y JavaScript. A partir de ahí, las APIs del navegador hacen el trabajo interesante.

Mi propia extensión ya ha pasado esa fase inicial y pronto compartiré más detalles. La parte difícil no es crear el popup. La parte difícil es decidir qué problema merece ocupar espacio permanente en el navegador.