Static Export & Sources

Experimental. Static export and Gatsby-compatible source plugins are new features. The API — including config shape, context object, and schema inference behaviour — may change in future releases without a major version bump.

Pre-render every page to a plain HTML file and deploy to any static host — no server required. Pull data from any CMS or API using Gatsby-compatible source plugins.

hadars export static

hadars export static builds the project and pre-renders a list of URL paths to index.html files. Each page also gets an index.json sidecar so useServerData keeps working on client-side navigation without a live server.

# Output goes to out/ by default
hadars export static

# Custom output directory
hadars export static dist

Minimal config

Add a paths function to hadars.config.ts that returns the list of URLs to pre-render. That's all that's required.

// hadars.config.ts
import type { HadarsOptions } from 'hadars';

export default {
    entry: './src/App.tsx',

    paths: () => ['/', '/about', '/contact'],
} satisfies HadarsOptions;

Output layout

out/
├── index.html          # /
├── index.json          # useServerData sidecar for /
├── about/
│   ├── index.html      # /about
│   └── index.json
├── contact/
│   ├── index.html      # /contact
│   └── index.json
└── static/             # JS, CSS, fonts — copied from .hadars/static/
    ├── index.js
    └── ...

Serve out/ from any static host — Vercel, Netlify, Cloudflare Pages, S3, or a plain nginx. No server-side code required.

Data in static pages

getInitProps receives a HadarsStaticContext as its second argument during static export. Use it to fetch data from a database, API, or the GraphQL layer (see below).

// src/App.tsx
import type { HadarsApp, HadarsRequest, HadarsStaticContext } from 'hadars';

interface Props { posts: Post[] }

const App: HadarsApp<Props> = ({ posts }) => (
    <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
);

export const getInitProps = async (
    req: HadarsRequest,
    ctx?: HadarsStaticContext,
): Promise<Props> => {
    // ctx is only present during static export (and dev with sources configured)
    if (!ctx) return { posts: [] };
    const { data } = await ctx.graphql('{ allPost { id title } }');
    return { posts: data?.allPost ?? [] };
};

export default App;

Source plugins

hadars source plugins follow the same API as Gatsby's sourceNodes — so most existing Gatsby CMS source plugins work out of the box. Each plugin creates typed nodes in an in-memory store; hadars infers a GraphQL schema automatically and exposes it to paths() and getInitProps().

During hadars dev, a GraphiQL IDE is served at /__hadars/graphql so you can explore the inferred schema while you build.

Install graphql

Schema inference requires graphql to be installed in your project:

npm install graphql

Config

Add a sources array to your config. Each entry mirrors Gatsby's plugin format: a resolve (package name or pre-imported module) and an optional options object.

// hadars.config.ts
import type { HadarsOptions, HadarsStaticContext } from 'hadars';

export default {
    entry: './src/App.tsx',

    sources: [
        {
            resolve: 'gatsby-source-contentful',
            options: {
                spaceId: process.env.CONTENTFUL_SPACE_ID,
                accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
            },
        },
    ],

    paths: async ({ graphql }: HadarsStaticContext) => {
        const { data } = await graphql(`{
            allContentfulBlogPost { slug }
        }`);
        const slugs = data?.allContentfulBlogPost?.map((p: any) => p.slug) ?? [];
        return ['/', ...slugs.map((s: string) => `/post/${s}`)];
    },
} satisfies HadarsOptions;

Local source plugin

Pass a pre-imported module instead of a package name to use a local plugin without publishing it to npm. The module must export a sourceNodes function.

// src/posts-source.ts
export async function sourceNodes(
    { actions, createNodeId, createContentDigest, reporter }: any,
    options: { dataDir: string } = {},
) {
    const { createNode } = actions;
    const posts = await fetchPostsFromMyApi();

    for (const post of posts) {
        createNode({
            ...post,
            id: createNodeId(post.slug),
            internal: {
                type: 'BlogPost',
                contentDigest: createContentDigest(post),
            },
        });
    }

    reporter.info(`Created ${posts.length} BlogPost nodes`);
}
// hadars.config.ts
import * as postsSource from './src/posts-source';

export default {
    entry: './src/App.tsx',
    sources: [
        { resolve: postsSource },
    ],
    paths: async ({ graphql }) => {
        const { data } = await graphql('{ allBlogPost { slug } }');
        return ['/', ...(data?.allBlogPost ?? []).map((p: any) => `/post/${p.slug}`)];
    },
} satisfies HadarsOptions;

Inferred GraphQL schema

hadars inspects the fields on each node type and generates a GraphQL schema automatically. For each type (e.g. BlogPost) you get two root queries:

QueryReturns
allBlogPostEvery BlogPost node
blogPost(id, slug, title, …)First node matching all supplied args

Scalar fields are automatically added as lookup arguments on the single-item query — so you can do blogPost(slug: "hello") without knowing the hashed node id.

# Explore your schema at /__hadars/graphql in dev mode
{
    allBlogPost {
        id
        slug
        title
        date
    }

    blogPost(slug: "hello-world") {
        title
        body
    }
}

useGraphQL hook

Query your GraphQL layer directly inside any component — no need to thread data through getInitProps. The hook integrates with useServerData so queries run on the server during static export and hydrate on the client at no extra cost.

import { useGraphQL } from 'hadars';
import { GetAllPostsDocument } from './gql/graphql';

const PostList = () => {
    const result = useGraphQL(GetAllPostsDocument);
    const posts = result?.data?.allBlogPost ?? [];
    return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
};

Pass variables as a second argument. When a typed DocumentNode from graphql-codegen is used, result.data has the exact inferred shape of your query — no casting needed.

const PostPage = ({ slug }: { slug: string }) => {
    const result = useGraphQL(GetPostDocument, { slug });
    const post = result?.data?.blogPost;
    if (!post) return null;
    return <h1>{post.title}</h1>;
};

result is undefined on the first SSR pass while the query is pending — render null or a skeleton. GraphQL errors throw during static export so the page is marked as failed rather than silently serving incomplete data.

GraphQL fragments

graphql-codegen's client preset generates fragment masking helpers (FragmentType, useFragment) that let components co-locate their exact data requirements. No hadars changes are needed — just define your fragment with the graphql() tag and accept a masked prop:

// src/PostCard.tsx
import { graphql, useFragment, type FragmentType } from './gql';

export const PostCardFragment = graphql(`
    fragment PostCard on BlogPost {
        slug
        title
        date
    }
`);

interface Props { post: FragmentType<typeof PostCardFragment> }

const PostCard = ({ post: postRef }: Props) => {
    const post = useFragment(PostCardFragment, postRef);
    return (
        <article>
            <h2>{post.title}</h2>
            <time>{post.date}</time>
        </article>
    );
};

The parent component spreads the raw node into the masked prop — TypeScript ensures it satisfies the fragment shape without any manual type assertions:

const PostList = () => {
    const result = useGraphQL(GetAllPostsDocument);
    return (
        <>
            {result?.data?.allBlogPost.map(post => (
                <PostCard key={post.slug} post={post} />
            ))}
        </>
    );
};

Schema export & type generation

Run hadars export schema to write the inferred schema to a SDL file, then use graphql-codegen to generate TypeScript types for your queries. Also works with a custom graphql executor — hadars introspects it automatically.

# 1. Generate schema.graphql from your sources
hadars export schema

# Custom output path
hadars export schema types/schema.graphql

# 2. Install codegen (one-time)
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations

# 3. Generate types
npx graphql-codegen --schema schema.graphql --documents "src/**/*.tsx" --out src/gql/

Or add a codegen.ts config file for more control:

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
    schema: 'schema.graphql',
    documents: ['src/**/*.tsx'],
    generates: {
        'src/gql/': {
            preset: 'client',
        },
    },
};

export default config;

Supported Gatsby context API

The following Gatsby sourceNodes context properties are implemented:

PropertyNotes
actions.createNodeAdds a node to the store
actions.deleteNodeNo-op (not needed on initial build)
actions.touchNodeNo-op
createNodeId(input)SHA-256 of pluginName + input
createContentDigest(obj)MD5 of JSON.stringify(obj)
getNode(id)Look up a node by id
getNodes()All nodes in the store
getNodesByType(type)All nodes of a given type
cache.get / cache.setIn-memory per-plugin cache
reporter.info/warn/error/panicLogs to console
emitterReal EventEmitter — BOOTSTRAP_FINISHED is emitted after settling

Custom GraphQL executor

If you prefer to manage your own schema — or need full control over resolvers — skip sources and provide a graphql executor directly. It will be passed to both paths() and getInitProps() as ctx.graphql.

import { graphql, buildSchema } from 'graphql';
import type { HadarsOptions } from 'hadars';

const schema = buildSchema(`
    type Post { id: ID! title: String slug: String }
    type Query { allPost: [Post!]! }
`);

const rootValue = {
    allPost: () => fetchPostsFromDb(),
};

export default {
    entry: './src/App.tsx',

    graphql: (query, variables) =>
        graphql({ schema, rootValue, source: query, variableValues: variables }),

    paths: async ({ graphql }) => {
        const { data } = await graphql('{ allPost { slug } }');
        return ['/', ...(data?.allPost ?? []).map((p: any) => `/post/${p.slug}`)];
    },
} satisfies HadarsOptions;