Build Hooks

Build hooks allow you to inject custom logic at various stages of the Stati build process. They provide powerful extension points for preprocessing content, integrating external services, generating additional files, and customizing the build pipeline.

Hook Lifecycle

Stati executes hooks in the following order:

1. beforeAll    → Called once at build start
2. Content Discovery & Processing
3. beforeRender → Called for each page
4. Template Rendering
5. afterRender  → Called for each page
6. Static Asset Copying
7. afterAll     → Called once at build end

Hook Types

beforeAll

Called once before starting the build process.

Use cases:

  • Initialize external services
  • Fetch remote data
  • Validate environment setup
  • Generate build metadata
export default defineConfig({
  hooks: {
    beforeAll: async (ctx) => {
      console.log(`Starting build with ${ctx.pages.length} pages`);

      // Initialize external services
      await initializeAnalytics();

      // Fetch remote data
      const apiData = await fetch('https://api.example.com/data');
      ctx.globalData = await apiData.json();

      // Validate environment
      if (!process.env.API_KEY) {
        throw new Error('API_KEY environment variable is required');
      }
    }
  }
});

afterAll

Called once after completing the build process.

Use cases:

  • Generate sitemaps and RSS feeds
  • Deploy to CDN or hosting service
  • Send build notifications
  • Clean up temporary resources
export default defineConfig({
  hooks: {
    afterAll: async (ctx) => {
      console.log(`Build complete! Generated ${ctx.pages.length} pages`);

      // Generate sitemap
      await generateSitemap(ctx.pages, ctx.config.site.baseUrl);

      // Generate RSS feed
      await generateRSSFeed(ctx.pages.filter(p => p.frontMatter.type === 'post'));

      // Deploy to CDN
      if (process.env.NODE_ENV === 'production') {
        await deployToCDN(ctx.config.outDir);
      }

      // Send notification
      await sendBuildNotification({
        status: 'success',
        pageCount: ctx.pages.length,
        buildTime: ctx.buildStats.duration
      });
    }
  }
});

beforeRender

Called before rendering each individual page.

Use cases:

  • Add dynamic data to pages
  • Calculate reading time or word count
  • Inject build metadata
  • Modify page content
export default defineConfig({
  hooks: {
    beforeRender: async (ctx) => {
      // Add build timestamp
      ctx.page.frontMatter.buildTime = new Date().toISOString();

      // Calculate reading time
      const wordCount = ctx.page.content.split(/\s+/).length;
      ctx.page.frontMatter.readingTime = Math.ceil(wordCount / 200);

      // Add previous/next navigation for blog posts
      if (ctx.page.url.startsWith('/blog/')) {
        const blogPosts = ctx.pages
          .filter(p => p.url.startsWith('/blog/') && p.url !== '/blog/')
          .sort((a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date));

        const currentIndex = blogPosts.findIndex(p => p.url === ctx.page.url);
        ctx.page.frontMatter.prevPost = blogPosts[currentIndex + 1] || null;
        ctx.page.frontMatter.nextPost = blogPosts[currentIndex - 1] || null;
      }

      // Inject global data
      ctx.page.frontMatter.siteData = ctx.globalData;
    }
  }
});

afterRender

Called after rendering each individual page.

Use cases:

  • Post-process generated HTML
  • Validate output
  • Generate search indices
  • Optimize images
export default defineConfig({
  hooks: {
    afterRender: async (ctx) => {
      console.log(`Rendered page: ${ctx.page.url}`);

      // Minify HTML in production
      if (process.env.NODE_ENV === 'production') {
        ctx.html = await minifyHTML(ctx.html);
      }

      // Extract headings for search index
      const headings = extractHeadings(ctx.html);
      await addToSearchIndex({
        url: ctx.page.url,
        title: ctx.page.frontMatter.title,
        content: ctx.page.content,
        headings: headings
      });

      // Validate HTML
      const validationErrors = await validateHTML(ctx.html);
      if (validationErrors.length > 0) {
        console.warn(`HTML validation warnings for ${ctx.page.url}:`, validationErrors);
      }
    }
  }
});

Hook Context

Each hook receives a context object with different properties:

beforeAll and afterAll Context

interface GlobalHookContext {
  pages: PageModel[];           // All discovered pages
  config: StatiConfig;          // Build configuration
  buildStats: BuildStats;       // Build timing and statistics
  globalData?: any;            // Shared data between hooks
}

beforeRender and afterRender Context

interface PageHookContext {
  page: PageModel;             // Current page being processed
  config: StatiConfig;         // Build configuration
  globalData?: any;           // Shared data from beforeAll
  html?: string;              // Generated HTML (afterRender only)
}

Advanced Hook Patterns

Shared Data Between Hooks

export default defineConfig({
  hooks: {
    beforeAll: async (ctx) => {
      // Fetch data once, share across all pages
      ctx.globalData = {
        posts: await fetchBlogPosts(),
        authors: await fetchAuthors(),
        buildId: generateBuildId()
      };
    },

    beforeRender: async (ctx) => {
      // Access shared data
      const { posts, authors } = ctx.globalData;

      // Add related posts
      if (ctx.page.frontMatter.type === 'post') {
        ctx.page.frontMatter.relatedPosts = findRelatedPosts(
          ctx.page,
          posts,
          3
        );
      }
    }
  }
});

Conditional Hook Execution

export default defineConfig({
  hooks: {
    beforeRender: async (ctx) => {
      // Only process blog posts
      if (!ctx.page.url.startsWith('/blog/')) {
        return;
      }

      // Add blog-specific data
      ctx.page.frontMatter.category = extractCategory(ctx.page.url);
      ctx.page.frontMatter.tags = normalizeTags(ctx.page.frontMatter.tags || []);
    },

    afterRender: async (ctx) => {
      // Only minify production builds
      if (process.env.NODE_ENV !== 'production') {
        return;
      }

      ctx.html = await minifyHTML(ctx.html);
    }
  }
});

Error Handling in Hooks

export default defineConfig({
  hooks: {
    beforeAll: async (ctx) => {
      try {
        ctx.globalData = await fetchExternalData();
      } catch (error) {
        console.warn('Failed to fetch external data, using fallback:', error.message);
        ctx.globalData = { fallback: true };
      }
    },

    beforeRender: async (ctx) => {
      try {
        await processPageData(ctx.page);
      } catch (error) {
        console.error(`Failed to process page ${ctx.page.url}:`, error);
        // Decide whether to continue or fail the build
        if (error.critical) {
          throw error; // Stop the build
        }
        // Otherwise continue with default data
      }
    }
  }
});

Async Hook Patterns

export default defineConfig({
  hooks: {
    beforeAll: async (ctx) => {
      // Parallel data fetching
      const [posts, authors, categories] = await Promise.all([
        fetchPosts(),
        fetchAuthors(),
        fetchCategories()
      ]);

      ctx.globalData = { posts, authors, categories };
    },

    afterAll: async (ctx) => {
      // Parallel deployment tasks
      await Promise.all([
        generateSitemap(ctx.pages),
        generateRSSFeed(ctx.pages),
        uploadToS3(ctx.config.outDir),
        purgeCloudflareCache()
      ]);
    }
  }
});

Hook Utilities

Page Filtering Helpers

const isPost = (page) => page.frontMatter.type === 'post';
const isDraft = (page) => page.frontMatter.draft === true;
const isPublished = (page) => !isDraft(page);
const isBlogContent = (page) => page.url.startsWith('/blog/');

export default defineConfig({
  hooks: {
    beforeAll: async (ctx) => {
      const publishedPosts = ctx.pages
        .filter(isPost)
        .filter(isPublished)
        .sort((a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date));

      ctx.globalData = { publishedPosts };
    }
  }
});

Content Processing Helpers

function extractHeadings(html) {
  const headings = [];
  const regex = /<h([1-6])[^>]*>([^<]+)<\/h[1-6]>/gi;
  let match;

  while ((match = regex.exec(html)) !== null) {
    headings.push({
      level: parseInt(match[1]),
      text: match[2].trim(),
      id: slugify(match[2])
    });
  }

  return headings;
}

function calculateReadingTime(content) {
  const wordsPerMinute = 200;
  const wordCount = content.split(/\s+/).length;
  return Math.ceil(wordCount / wordsPerMinute);
}

Real-World Examples

export default defineConfig({
  hooks: {
    beforeRender: async (ctx) => {
      if (ctx.page.frontMatter.type !== 'post') return;

      // Find related posts by tags
      const currentTags = ctx.page.frontMatter.tags || [];
      const allPosts = ctx.pages.filter(p => p.frontMatter.type === 'post');

      const relatedPosts = allPosts
        .filter(p => p.url !== ctx.page.url)
        .map(p => ({
          ...p,
          score: intersection(currentTags, p.frontMatter.tags || []).length
        }))
        .filter(p => p.score > 0)
        .sort((a, b) => b.score - a.score)
        .slice(0, 3);

      ctx.page.frontMatter.relatedPosts = relatedPosts;
    }
  }
});

Build hooks provide powerful extensibility while maintaining Stati’s performance and simplicity. Use them to integrate external services, process content dynamically, and customize the build pipeline to match your specific needs.