@lewebsimple/nuxt-graphql

Opinionated Nuxt module for using GraphQL
9,518 1
Install
npm install @lewebsimple/nuxt-graphql
Latest Version:0.7.17
Vue:3
License:AGPL-3.0-only
Last Updated:Aug 28, 2026
Links: GitHub  ·  npm
Maintainer: pascalmartineau

Nuxt GraphQL

npm version npm downloads License Nuxt

Opinionated Nuxt module that wires a typed GraphQL server + client into your app.

✨ Release Notes

Features

  • 🧘 GraphQL Yoga server at /api/graphql (GraphiQL in dev) + SSE subscriptions
  • 🪡 Combined schema from local and/or remote schemas (remote introspection at build time; subscriptions stripped)
  • 🪄 Code generation from .gql documents and source files → validated schema operations and fragments
  • 🧠 Type-safe helpers for queries, mutations, and subscriptions in both client + server
  • 🧊 SSR-friendly by default: request header forwarding + remote schema execution hooks
  • 🚀 Client-side cache for useAsyncGraphQLQuery (cache policies + optional persistence in localStorage)
  • 🧯 Unified error model via ExecuteGraphQLResult and NormalizedError

Getting started

Install the module and its dependencies in your project:

pnpm add @lewebsimple/nuxt-graphql @lewebsimple/graphql-codegen-zod graphql zod

Configuration

Add the module to your nuxt.config.ts and declare your schemas, context, documents glob and optional client cache:

export default defineNuxtConfig({
  modules: ["@lewebsimple/nuxt-graphql"],
  graphql: {
    // GraphQL server configuration
    server: {
      // Optional: custom GraphQL context factories (defaults to empty context)
      context: ["server/graphql/context.ts"],

      // Schemas to stitch together (local and/or remote)
      schema: [
        // Local schema example
        { type: "local", path: "server/graphql/schema.ts" },

        // Remote schema example
        {
          type: "remote",
          endpoint: "https://swapi-graphql.netlify.app/graphql",
          // Optional: static headers for introspection and execution.
          // ⚠️ These are attached to every proxied request — see "Security considerations".
          headers: {
            "X-Static-Header": "static-header-value",
          },
          // Optional: per-remote execution hooks
          hooks: ["server/graphql/swapi-hooks.ts"],
        },
      ],
    },

    // GraphQL client configuration
    client: {
      // Optional: documents globs
      // (defaults to ["app/**/*.{gql,ts,vue}", "server/**/*.{gql,ts}", "shared/**/*.{gql,ts}"])
      documents: ["app/graphql/**/*.gql"],

      // Optional: incoming request headers forwarded to GraphQL calls during SSR
      // (defaults to ["authorization", "cookie"]; names are matched case-insensitively)
      ssrForwardHeaders: ["authorization", "cookie"],

      // Optional: query caching (client-side only, for useAsyncGraphQLQuery)
      cache: {
        policy: "cache-first", // "no-cache" | "cache-first" | "network-first" | "swr"
        keyVersion: "1",
        keyPrefix: "gql",
        // Persist cache entries in localStorage with TTL in seconds
        // - 0 = never expires
        // - undefined = persistence disabled
        ttl: 60,
      },
    },

    // Optional: save path for the stitched SDL (defaults to ".nuxt/graphql/schema.graphql")
    saveSDL: "server/graphql/schema.graphql",

    // Optional: save path for the generated GraphQL config (defaults to "graphql.config.json")
    saveConfig: "graphql.config.json",
  },
});

Define GraphQL schema (local and/or remote)

Local schemas must live under server/ and export a GraphQLSchema as schema.

For the example configuration above, create server/graphql/schema.ts:

import { createSchema } from "graphql-yoga";
import type { GraphQLContext } from "#graphql/context";

export const schema = createSchema<GraphQLContext>({
  typeDefs: /* GraphQL */ `
    type Query {
      hello: String!
    }
    type Mutation {
      ping(message: String!): String!
    }
    type Subscription {
      time: String!
    }
  `,
  resolvers: {
    Query: {
      hello: () => "Hello from Nuxt GraphQL!",
    },
    Mutation: {
      ping: (_parent, args) => `pong: ${args.message}`,
    },
    Subscription: {
      time: {
        subscribe: async function* () {
          while (true) {
            yield { time: new Date().toISOString() };
            await new Promise((r) => setTimeout(r, 1000));
          }
        },
      },
    },
  },
});

Remote schemas are introspected at build time from the endpoint URL and executed via an HTTP executor at runtime. Subscriptions are stripped from remote schemas.

The final schema combines the all of the defined local / remote schemas.

Define GraphQL context (optional)

Context definition is optional. Factories run in parallel (a factory cannot read another factory's context) and their results are merged in configuration order — when two factories return the same key, the later one wins. Their return types are merged into a single GraphQLContext type which is exported from #graphql/context. You can use the auto-imported defineGraphQLContext helper for type-safety.

The context is created once per GraphQL execution (not per HTTP request): a server render firing several queries runs the factories once for each. Keep factories cheap, or memoize expensive lookups on event.context.

For example, create server/graphql/context.ts:

import { getUserSession } from "nuxt-auth-utils";

export default defineGraphQLContext(async (event) => {
  const session = await getUserSession(event);
  return {
    user: session?.user ?? null,
  };
});

Write GraphQL documents (.gql)

By default, the module scans app/, server/, and shared/ for .gql files and for GraphQL documents embedded in .ts / .vue sources. Named operations and fragments are converted into types and typed document nodes. The operations are exposed by name in #graphql/registry to allow type-safe execution with the provided composables and server utils.

⚠️ Operation names are required and must be unique.

Example document files:

# app/graphql/HelloWorld.query.gql
query HelloWorld {
  hello
}
# app/graphql/Ping.mutation.gql
mutation Ping($message: String!) {
  ping(message: $message)
}
# app/graphql/Time.subscription.gql
subscription Time {
  time
}

That's it! You can now use Nuxt GraphQL in your Nuxt app ✨

Fragments

Fragments are fully supported and are the recommended way to share selection sets across operations.

  • Fragment names must be unique across all .gql files (duplicates throw during generation).
  • Fragments are not executable by themselves and are not part of the registry.

Example with a fragment:

# app/graphql/SwapiFilms.query.gql
fragment TheFilm on Film {
  title
  releaseDate
}

query SwapiFilms {
  allFilms {
    films {
      ...TheFilm
    }
  }
}

From TypeScript, you can also use fragment types explicitly when needed (see below):

import type { TheFilmFragment, SwapiFilmsVariables } from "#graphql/types";

⚠️ These types are inferred from the Zod schemas and cannot be used as top-level in component props, i.e. defineProps<TheFilmFragment>() breaks but defineProps<{ film: TheFilmFragment }>() works just fine.

Use the auto-imported composables

The auto-imported composables allow executing queries, mutations, and subscriptions based on their registry name with full type-safety (variables and return value).

// Cached query via useAsyncData
const { data, pending, error, refresh } = await useAsyncGraphQLQuery("HelloWorld", {});

// Direct HTTP query (SafeResult)
const { data: queryData, error: queryError } = await useGraphQLQuery("HelloWorld", {});

// Mutation (SafeResult)
const { mutate, pending: mutationPending } = useGraphQLMutation("Ping");
const { data: mutationData, error: mutationError } = await mutate({ message: "Hello!" });

// Subscription (client-only, SSE)
const { data, error, start, stop } = useGraphQLSubscription("Time", {});

Cursor pagination with useGraphQLLoadMore

useGraphQLLoadMore wraps useAsyncGraphQLQuery for Relay-style cursor pagination (after variable + pageInfo { hasNextPage endCursor }). It accumulates the nodes of every fetched page into items:

const { items, pending, error, hasNextPage, isLoadingMore, loadMore, reset } =
  await useGraphQLLoadMore("FilmList", { first: 10 }, (data) => data?.allFilms);
  • variables excludes after (managed internally); changing them resets the pagination.
  • getConnection extracts the connection ({ nodes, pageInfo }) from the query result.
  • The query uses the global cache configuration (no per-query cache overrides).
  • With the swr policy, a background revalidation of the first page replaces items; revalidations of later pages do not re-splice the accumulated list.

Use the auto-imported server utils

In server routes, you can execute queries and mutations directly against the stitched schema (no HTTP roundtrip):

export default defineEventHandler(async (event) => {
  // Server-side GraphQL query example
  const { data, error } = await executeSchemaOperation(event, {
    operationName: "HelloWorld",
    variables: {},
  });

  return { data, error };
});

Server helpers return a ExecuteGraphQLResult in the same format as some composables, i.e. { data: TResult, error: null } | { data: null, error: NormalizedError }

Error handling

Failures are normalized into a NormalizedError (an Error subclass) everywhere — composables, server utils, and useAsyncGraphQLQuery's error ref:

  • message: aggregated GraphQL error messages, or the transport error message.
  • errors: the original GraphQL errors (empty for non-GraphQL failures).
  • code: typed error code — "NETWORK_ERROR" for fetch/transport failures, otherwise the extensions.code of the first GraphQL error ("UNAUTHORIZED", "FORBIDDEN", "BAD_REQUEST", "INTERNAL_ERROR"). Augment the GraphQLErrorCodeMap interface to add your own codes.
  • status: HTTP status for transport failures, when available.

Type-safety

All enum, fragment and operation variables & result types are re-exported from #graphql/types for your convenience:

import type { TheFilmFragment } from "#graphql/types";

Query caching (client-side only)

useAsyncGraphQLQuery can cache query results based on the global cache configuration and per-query overrides.

  • In-flight requests are deduplicated (same operation + variables → one network call); a failed request clears the in-flight slot so the next call retries instead of reusing the rejection.
  • The in-memory cache is bound to the Nuxt app instance, so it is a singleton on the client and scoped to a single render on the server. Concurrent server requests never share cached results or in-flight requests, even when they resolve the same cache key.
  • Cached results are stored untransformed: a per-query transform is applied by useAsyncData on top of the cached value, never baked into it.
  • Persisted cache stores entries in localStorage for ttl seconds (0 = never expires). On the client, the policies that read the cache hydrate from localStorage after a reload, keeping the original expiration.
Cache policies
  • "no-cache": always fetches from the network (still dedupes in-flight).
  • "cache-first": returns cached value when present, otherwise fetches.
  • "network-first": tries the network first, falls back to cached value on error.
  • "swr": returns cached value immediately and refreshes in the background.

Notes on swr:

  • A revalidation happens when the query handler runs again for a cached key: an explicit refresh(), or reactive variables changing back to previously-seen values. Mounting a component on a hydrated key does not by itself revalidate.
  • Background revalidation errors are swallowed silently — the previously rendered data stays in place. If you need to surface them, use network-first or an explicit refresh() (whose error lands in the error ref).
Per-query overrides
const { data } = await useAsyncGraphQLQuery(
  "HelloWorld",
  {},
  {
    cache: {
      policy: "network-first",
      ttl: undefined, // disable persistence for this call
    },
  },
);
Cache manipulation

On the client, useGraphQLCache() provides helpers to read, write, update, and invalidate cache entries:

const cache = useGraphQLCache();

// Read cached query (in-memory only)
const films = cache.read("AllFilms", {});

// Write cached query synchronously (in-memory only, useful for rollbacks)
cache.write("AllFilms", {}, newValue);
cache.write("AllFilms", {}, (current) => ({ ...current, films: [...current.films, newFilm] }));

// Update cached query asynchronously (in-memory + persisted)
await cache.update("AllFilms", {}, newValue);
await cache.update("AllFilms", {}, (current) => ({
  ...current,
  films: [...current.films, newFilm],
}));

// Invalidate cache entries
await cache.invalidate("HelloWorld"); // All entries for operation
await cache.invalidate(); // All entries
Optimistic updates

useGraphQLMutation supports optimistic updates via lifecycle hooks:

const { mutate } = useGraphQLMutation("AddFilm", {
  onMutate: async (variables) => {
    const cache = useGraphQLCache();

    // Snapshot current value for rollback
    const snapshot = cache.read("AllFilms", {});

    // Optimistically update cache
    await cache.update("AllFilms", {}, (current) => ({
      films: [...(current?.films ?? []), { id: "temp", title: variables.title }],
    }));

    return { snapshot };
  },

  onError: (error, variables, context) => {
    const cache = useGraphQLCache();
    // Rollback on error (sync for instant UI update)
    if (context?.snapshot) {
      cache.write("AllFilms", {}, context.snapshot);
    }
  },

  onSuccess: (data, variables, context) => {
    // Replace optimistic temp ID with real ID from server
    const cache = useGraphQLCache();
    cache.update("AllFilms", {}, (current) => ({
      films: current?.films.map((f) => (f.id === "temp" ? data.addFilm : f)) ?? [],
    }));
  },

  onSettled: (result, variables, context) => {
    // Always runs after mutation (success or error)
    console.log("Mutation completed");
  },
});

const result = await mutate({ title: "New Film" });

Remote executor hooks (optional, per remote)

You can define custom logic around the remote executor for each remote schema by using the auto-imported defineRemoteExecutorHooks helper.

All hooks receive the GraphQL context as a second parameter for convenient access.

For the example configuration above, create server/graphql/swapi-hooks.ts:

import { defu } from "defu";

export default defineRemoteExecutorHooks({
  onRequest(request, context) {
    // Context is available as second parameter
    const { remoteAuthToken } = context || {};
    request.extensions = defu(request.extensions, {
      headers: {
        Authorization: `Bearer ${remoteAuthToken || ""}`,
      },
    });
  },

  onResult(result, context, meta) {
    // You can also access context in onResult
    console.log("User from context:", context?.user);
    console.log("Result:", result.data);
    // Upstream HTTP response metadata (headers + status)
    const session = meta.headers.get("woocommerce-session");
    if (session) context?.setSession?.(session);
  },

  onError(error, context, meta) {
    // And in onError for logging/monitoring
    // `meta` is undefined when the failure happened before the HTTP response
    // (e.g. fetch/network error); defined for non-2xx or JSON parse errors.
    console.error("Remote execution failed for user:", context?.user?.id, meta?.status);
  },
});

Security considerations

/api/graphql is a public GraphQL endpoint accepting arbitrary operations — it is not limited to the operations in the registry:

  • With a remote schema, the endpoint acts as a proxy: the configured static headers are attached to every proxied request. Never put privileged credentials there unless the remote data is meant to be fully public; use execution hooks to derive per-user credentials from the context instead.
  • Introspection is enabled (GraphiQL itself is dev-only). Anyone can enumerate the full stitched schema.
  • No depth, complexity, or rate limits are applied. Put the endpoint behind your own middleware if you need them.

An operation-allowlist mode (restricting the endpoint to registry operations) is planned.

Known limitations

  • The client cache has no eviction: entries accumulate per operation + variables (e.g. pagination cursors) for the lifetime of the tab. Long-lived sessions with many distinct variable sets grow memory accordingly.
  • Subscriptions are stripped from remote schemas (local schemas only).
  • useGraphQLCache reads/writes raw (untransformed) results; for queries using a transform, prefer invalidation over manual writes.

Contribution

Local development
# Install dependencies
pnpm install

# Generate type stubs
pnpm run dev:prepare

# Develop with the playground
pnpm run dev

# Build the playground
pnpm run dev:build

# Run ESLint
pnpm run lint

# Run Vitest
pnpm run test
pnpm run test:watch

# Release new version
pnpm run release

Related Packages

@lewebsimple/nuxt3-svg

Simple SVG loader for Nuxt 3

1,222 2

Version History

Version Released Vue License
0.7.17 ^3.0 AGPL-3.0-only
0.7.16 ^3.0 AGPL-3.0-only
0.7.15 ^3.0 AGPL-3.0-only
0.7.14 ^3.0 AGPL-3.0-only
0.7.13 ^3.0 AGPL-3.0-only