• Home
BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. Next.js searchParams Disables Static Generation — Here's the Architectural Fix

Next.js searchParams Disables Static Generation — Here's the Architectural Fix

Route separation for Next.js 15/16 — keep 90% of your pages static while preserving dynamic searchParams where you need it

5th October 2025·Updated on:3rd March 2026·MŽMatija Žiberna·
Next.js
Next.js searchParams Disables Static Generation — Here's the Architectural Fix

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •How to Pass searchParams in Payload CMS Blocks Using Next.js 15.4+ Server Components
  • •How to set-up livePreview in Payload with Nextjs's draftMode
  • •How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams

Accepting searchParams in a Next.js App Router page component opts that entire route into dynamic rendering. Not just the pages that use filtering — every page served through that route. If you have a catch-all route handling both static content pages and dynamic product pages, one searchParams prop forces all of them to render on every request.

The fix is architectural: separate routes by rendering requirements. Here's exactly how I restructured a Payload CMS site to keep 90% of pages static while maintaining full dynamic filtering for product variants — and why reaching for force-dynamic is the wrong instinct.

Why searchParams Disables Static Generation

Next.js cannot pre-render a page at build time if it depends on data that only exists at request time. searchParams — the query string values in the URL — is one of those request-time dependencies. The moment you accept it in a page component, Next.js has no choice: that route must render dynamically on every request.

This is by design, not a bug. The same rule applies to cookies(), headers(), and draftMode() — all of them are dynamic APIs that opt the route out of static generation.

If you've hit this error in your build output:

"pages that use searchParams must be rendered dynamically"

You're in the right place. The rest of this article explains exactly how to fix it without giving up static generation for your entire site.

Next.js 15/16: searchParams Is Now Async

Before diving into the route separation fix, there's a related breaking change worth understanding if you're on Next.js 15 or 16.

In Next.js 15, params and searchParams in page.js became asynchronous Promises. The same applies to cookies(), headers(), and draftMode(). If you access these synchronously, you'll see:

Cannot access Request information synchronously with Page or Layout or Route params or Page searchParams

The migration path is to make your component async and await the props:

// Before (Next.js 14 and earlier)
export default function Page({ params, searchParams }) {
  const { slug } = params;
  const { color } = searchParams;
}

// After (Next.js 15/16)
export default async function Page(props) {
  const params = await props.params;
  const searchParams = await props.searchParams;
  const { slug } = params;
  const { color } = searchParams;
}

The official codemod handles this automatically:

npx @next/codemod@latest next-async-request-api .

[!IMPORTANT] Making searchParams async does not make your route static. These are two separate concerns. Awaiting searchParams is required in Next.js 15/16 — but the route is still dynamic. You need route separation to restore static generation.

The Problem: searchParams Forces Dynamic Rendering on All Pages

I had a typical Next.js App Router setup with a catch-all route handling multiple page types. The route structure looked like this:

// File: src/app/(frontend)/[...slug]/page.tsx
export default async function SlugPage({
  params: paramsPromise,
  searchParams: searchParamsPromise,
}: {
  params: Promise<{ slug?: string[] }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const params = await paramsPromise;
  const searchParams = await searchParamsPromise;
  const { slug } = params;

  // Handle different page types
  const pageType = getPageTypeFromSlug(slug);

  // Product pages used searchParams for variant filtering
  // But general pages, service pages, and project pages didn't need it

  const page = await getPageBySlug(slug);
  return <>{renderPageBlocks(page, searchParams)}</>;
}

This single route handled everything: homepage, general pages, service pages, project pages, and product pages. The product pages needed searchParams for filtering product variants by color, size, and other attributes. But here's the critical issue I discovered in the Next.js documentation: any page component that accepts searchParams cannot be statically generated.

When I ran pnpm run build, the output confirmed my suspicions:

Route (app)                              Size  First Load JS
├ ƒ /                                   260 B         373 kB
├ ƒ /[...slug]                          260 B         373 kB
├ ƒ /storitve/[slug]                    260 B         373 kB

ƒ  (Dynamic)  server-rendered on demand

Every page marked with ƒ (Dynamic) meant zero static generation benefits. My homepage, about page, contact page—all being server-rendered on every request despite having completely static content.

If your first instinct is to add export const dynamic = 'force-dynamic' to silence this — stop. That's the wrong fix and it makes things worse. Jump to why →

Understanding the Static Generation Constraint

Next.js App Router has a strict rule: pages that use searchParams must be rendered dynamically because the search parameters are only known at request time. This makes complete sense for pages that genuinely need dynamic filtering or state management through URL parameters. But in my case, only product pages needed this functionality.

The problem was architectural. By handling all page types in a single catch-all route that accepted searchParams, I had inadvertently forced every page through dynamic rendering. The solution required separating concerns: static pages should live in one route structure, and dynamic pages requiring searchParams should have their own dedicated route.

Step 1: Analyzing Which Pages Actually Need searchParams

Before making any changes, I audited exactly which pages used the searchParams functionality. The breakdown looked like this:

Pages NOT using searchParams (90% of content):

  • Homepage (/)
  • General pages (/about, /contact, /privacy-policy)
  • Service pages (/storitve/[slug])
  • Project pages (/projekti/[slug])

Pages USING searchParams (10% of content):

  • Product pages (/izdelki/[slug]?color=red&size=large)

The product pages needed searchParams because they implemented variant filtering. When users selected a color or size option, the ProductVariantSelector component updated the URL with query parameters, and the server component read those parameters to display the correct variant pricing and availability.

This 90/10 split made the solution clear: extract product pages into their own route and let the catch-all route be fully static.

Step 2: Creating a Dedicated Dynamic Route for Products

The first step was creating a new route specifically for product pages at /src/app/(frontend)/izdelki/[slug]/page.tsx. This route would be the only one accepting searchParams.

// File: src/app/(frontend)/izdelki/[slug]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import React from "react";
import type { Metadata } from "next";

import { queryProductPageBySlug, queryAllProductSlugs } from "@/lib/payload";
import type { ProductPage } from "@payload-types";
import { RenderProductPageBlocks } from "@/blocks/RenderProductPageBlocks";
import { generatePageSEOMetadata } from "@/utilities/seo";
import { getOgParamsFromPage, getOgImageUrl } from "@/lib/og-image";

type Props = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
};

export async function generateStaticParams() {
  try {
    const slugs = await queryAllProductSlugs();
    return slugs.map((slug: string) => ({
      slug,
    }));
  } catch (error) {
    console.error("Error generating static params for products:", error);
    return [];
  }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const { slug } = resolvedParams;

  let page: ProductPage | null = null;

  try {
    page = await queryProductPageBySlug({ slug, overrideAccess: false, draft: false });
  } catch (error) {
    console.error("Error fetching product page for metadata:", error);
  }

  if (!page) {
    return {
      title: "Izdelek - Laneks",
      description: "Profesionalne storitve za vaš dom in podjetje",
    };
  }

  let ogImageUrl: string | undefined = undefined;

  if (page) {
    const ogParams = await getOgParamsFromPage(page);
    if (ogParams) {
      ogImageUrl = getOgImageUrl(ogParams);
    }
  }

  return generatePageSEOMetadata(page, [slug], { ogImageUrl });
}

export default async function ProductPage({
  params: paramsPromise,
  searchParams: searchParamsPromise,
}: Props) {
  const params = await paramsPromise;
  const searchParams = await searchParamsPromise;
  const { slug } = params;

  const { isEnabled: draft } = await draftMode();

  const page = await queryProductPageBySlug({
    slug,
    overrideAccess: false,
    draft
  });

  if (!page) {
    return notFound();
  }

  return (
    <RenderProductPageBlocks
      pageType={page.pageType}
      blocks={page.layout}
      searchParams={searchParams}
    />
  );
}

This dedicated product route maintains all the dynamic functionality needed for variant filtering. The key difference from the catch-all route is that this one expects a single slug string rather than an array, and it's specifically designed to handle product pages only.

Notice that we still use generateStaticParams() even though this route accepts searchParams. This generates static HTML for the base product URLs without query parameters. When users add filter parameters like ?color=red, Next.js dynamically renders those variations while still benefiting from static generation for the initial page load.

Step 3: Removing searchParams from the Catch-All Route

With product pages handled separately, I could now remove all searchParams logic from the main catch-all route. This required several coordinated changes.

First, I updated the Props interface to remove searchParams:

// File: src/app/(frontend)/[...slug]/page.tsx
type Props = {
  params: Promise<{ slug?: string[] }>;
  // searchParams removed - no longer needed
};

Next, I removed product page handling from the route configuration:

// Before
const ROUTE_CONFIGS = {
  storitve: "service",
  projekti: "project",
  izdelki: "product",
} as const;

// After
const ROUTE_CONFIGS = {
  storitve: "service",
  projekti: "project",
} as const;

This constant maps URL prefixes to page types. Removing izdelki meant the catch-all route would no longer attempt to handle product pages, delegating that responsibility to the dedicated route.

I updated the type definitions to reflect the removal:

// Before
type AnyPageType = Page | ServicePage | ProjectPage | ProductPage;

// After
type AnyPageType = Page | ServicePage | ProjectPage;

Then I removed product-specific logic from the query function:

// Before
switch (pageType) {
  case "service":
    return queryServicePageBySlug({ slug: slug[1], overrideAccess, draft });
  case "project":
    return queryProjectPageBySlug({ slug: slug[1], overrideAccess, draft });
  case "product":
    return queryProductPageBySlug({ slug: slug[1], overrideAccess, draft });
  default:
    return queryPageBySlug({ slug, overrideAccess, draft });
}

// After
switch (pageType) {
  case "service":
    return queryServicePageBySlug({ slug: slug[1], overrideAccess, draft });
  case "project":
    return queryProjectPageBySlug({ slug: slug[1], overrideAccess, draft });
  default:
    return queryPageBySlug({ slug, overrideAccess, draft });
}

Finally, I updated the render function to remove searchParams entirely:

// Before
function renderPageBlocks(
  page: AnyPageType,
  searchParams: Record<string, string | string[] | undefined>,
) {
  switch (page.pageType) {
    case "service":
      return <RenderServicesPageBlocks pageType={page.pageType} blocks={page.layout} searchParams={searchParams} />;
    // ... other cases
  }
}

// After
function renderPageBlocks(page: AnyPageType) {
  switch (page.pageType) {
    case "service":
      return <RenderServicesPageBlocks pageType={page.pageType} blocks={page.layout} />;
    // ... other cases
  }
}

And updated the main component signature:

// File: src/app/(frontend)/[...slug]/page.tsx
export default async function SlugPage({
  params: paramsPromise,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const params = await paramsPromise;
  // No searchParams destructuring needed anymore

  // ... rest of implementation
  return <>{renderPageBlocks(page)}</>;
}

These changes systematically removed all product page handling and searchParams dependencies from the catch-all route, making it eligible for static generation.

Step 4: Updating Static Path Generation

With the route separation complete, I needed to ensure that the static path generation logic didn't create conflicts. The issue was that both routes had generateStaticParams() functions, and I needed to make sure they didn't overlap.

I updated the main static paths function to exclude product pages:

// File: src/lib/payload/index.ts
export const getStaticPaths = async (): Promise<{ slug: string[] }[]> => {
  return await unstable_cache(
    async () => {
      const payload = await getPayloadClient();
      const staticPaths: { slug: string[] }[] = [];

      try {
        const pages = await payload.find({
          collection: "pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        const servicePages = await payload.find({
          collection: "service-pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        const projectPages = await payload.find({
          collection: "project-pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        // Add regular pages (excluding home page - handled by dedicated /page.tsx)
        pages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string" && page.slug !== "home") {
            staticPaths.push({ slug: [page.slug] });
          }
        });

        // Add service pages with language prefixes
        const serviceLanguages = ["storitve", "tretmaji"];
        servicePages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string") {
            serviceLanguages.forEach((lang) => {
              staticPaths.push({ slug: [lang, page.slug as string] });
            });
          }
        });

        // Add project pages with language prefixes
        const projectLanguages = ["projekti"];
        projectPages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string") {
            projectLanguages.forEach((lang) => {
              staticPaths.push({ slug: [lang, page.slug as string] });
            });
          }
        });

        // Product pages removed - handled by dedicated route

        return staticPaths;
      } catch (error) {
        console.error("Error generating static params:", error);
        return [];
      }
    },
    [CACHE_KEY.STATIC_PATHS()],
    {
      tags: [TAGS.PAGES, TAGS.SERVICE_PAGES, TAGS.PROJECT_PAGES],
      revalidate: false,
    },
  )();
};

The critical change here is removing the product pages from the static paths generation for the catch-all route. This prevents the Next.js build error "The provided export path doesn't match the page" that occurs when multiple routes try to generate the same path.

I also needed to exclude the homepage from the catch-all route's static params since it's handled by a dedicated /page.tsx file. This is a common pattern in Next.js where the root page uses the catch-all route's logic but is defined separately.

Then I created a new function to generate product slugs for the dedicated route:

// File: src/lib/payload/index.ts
export const queryAllProductSlugs = async (): Promise<string[]> => {
  return await unstable_cache(
    async () => {
      const payload = await getPayloadClient();
      const productPages = await payload.find({
        collection: "product-pages",
        limit: 1000,
        where: { _status: { equals: "published" } },
        select: { slug: true },
      });

      return productPages.docs
        .filter((page) => page.slug && typeof page.slug === "string")
        .map((page) => page.slug as string);
    },
    [CACHE_KEY.PRODUCT_SLUGS()],
    {
      tags: [TAGS.PRODUCT_PAGES],
      revalidate: false,
    },
  )();
};

This function is called by the product route's generateStaticParams() to get all product slugs for static generation. By separating this logic, each route has clear ownership of its static path generation.

I also added the corresponding cache key:

// File: src/lib/payload/cache-keys.ts
export const CACHE_KEY = {
  // ... other keys
  PRODUCT_SLUGS: () => "product-slugs-all",
};

Step 5: Fixing the Homepage Implementation

One tricky aspect of this refactoring was the homepage. In Next.js App Router, you typically have a root /page.tsx that handles the homepage separately from dynamic routes. My implementation used an elegant pattern to reuse the catch-all route logic:

// File: src/app/(frontend)/page.tsx
import PageTemplate, { generateMetadata } from './[...slug]/page'

export default PageTemplate

export { generateMetadata }

This works because when Next.js calls the PageTemplate component from the root page, it automatically provides the params as an empty slug array, which the catch-all route interprets as the homepage. This approach avoids code duplication while maintaining clean separation of concerns.

However, after removing searchParams from the catch-all route, this pattern continued to work seamlessly without any changes needed. This demonstrates one of the benefits of the refactoring—the interfaces became simpler and more predictable.

Step 6: Updating Component Interfaces

The final implementation step involved updating all the render block components to make searchParams optional. This maintains backward compatibility while reflecting the new architecture.

// File: src/blocks/RenderServicesPageBlocks.tsx
export const RenderServicesPageBlocks: React.FC<{
  pageType: ServicePage["pageType"];
  blocks: ServicePage["layout"];
  searchParams?: Record<string, string | string[] | undefined>; // Now optional
}> = (props) => {
  const { blocks, pageType, searchParams } = props;

  // Component implementation
};

I made the same change to RenderGeneralPageBlocks and RenderProjectPageBlocks. While these components no longer receive searchParams from the catch-all route, making it optional rather than removing it entirely provides flexibility for future use cases and maintains the component interface consistency.

For the product blocks, searchParams remains required since it's essential functionality:

// File: src/blocks/RenderProductPageBlocks.tsx
export const RenderProductPageBlocks: React.FC<{
  pageType: ProductPage["pageType"];
  blocks: ProductPage["layout"];
  searchParams: Record<string, string | string[] | undefined>; // Required
}> = (props) => {
  // Product-specific implementation that uses searchParams
};

Step 7: Resolving TypeScript and Build Errors

After making all the structural changes, I ran into several TypeScript compilation errors that needed resolution. The first was in the preview route handler:

Type error: Type 'typeof import("/src/app/(frontend)/next/preview/route")' does not satisfy the constraint 'RouteHandlerConfig<"/next/preview">'.
  Types of property 'GET' are incompatible.

This error was unrelated to the route refactoring but surfaced during the build. The issue was that the preview route was using a custom request type instead of Next.js's NextRequest:

// File: src/app/(frontend)/next/preview/route.ts
// Before
export async function GET(
  req: {
    cookies: {
      get: (name: string) => { value: string; };
    };
  } & Request,
): Promise<Response> {

// After
import { NextRequest } from "next/server";

export async function GET(req: NextRequest): Promise<Response> {
  // Implementation
}

Using NextRequest resolves the type incompatibility and follows Next.js best practices for route handlers.

I also needed to update the TypeScript hints in RenderProjectPageBlocks to avoid unused @ts-expect-error directive warnings:

// File: src/blocks/RenderProjectPageBlocks.tsx
<Block
  {...block}
  // @ts-expect-error disableInnerContainer may not exist on all blocks
  disableInnerContainer
  // @ts-expect-error searchParams may not exist on all blocks
  searchParams={searchParams}
/>

This properly documents why we're suppressing TypeScript errors for props that may not exist on all block component types.


## Don't Use force-dynamic to Fix This {#dont-use-force-dynamic}

The most common wrong answer you'll find on Stack Overflow and GitHub is this:

```typescript
// ❌ Wrong fix
export const dynamic = 'force-dynamic';

export default async function Page({ searchParams }) {
  // ...
}

export const dynamic = 'force-dynamic' does silence the build error. But here's what it actually does:

  • Makes the entire route permanently dynamic — every request, every time, no exceptions
  • Sets all fetches on that route to no-store, disabling data caching too
  • Gives up static generation benefits not just for your dynamic pages, but for every page in that route segment

In the catch-all route scenario, this means your homepage, about page, and every static content page gets server-rendered on demand — exactly the problem you were trying to solve.

force-dynamic is the nuclear option. It has legitimate uses (routes that genuinely need per-request rendering with no caching at all), but it's not a fix for the searchParams static generation problem. It's just a way to stop seeing the error while accepting worse performance.

The correct fix is route separation: move the pages that need searchParams into their own dedicated route, as shown in the steps above.

After completing all the changes, I ran the production build to verify the optimization worked:

pnpm run build

The build output showed exactly what I was hoping for:

Route (app)                              Size  First Load JS  Revalidate
┌ ○ /                                   254 B         377 kB         15m
├ ● /[...slug]                          254 B         377 kB         15m
├   ├ /politika-piskotkov                                             15m
├   ├ /pogoji-poslovanja                                              15m
├   └ [+14 more paths]
├ ● /izdelki/[slug]                   20.9 kB         405 kB         15m
├   ├ /izdelki/cistilna-naprava-roeco-8-6000l                         15m
├   ├ /izdelki/cistilna-naprava-roeco-5-4000l                         15m
└   └ [+2 more paths]

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)

The symbols tell the story:

  • ○ (Static) - The homepage is now fully static
  • ● (SSG) - The catch-all route and product route both use static site generation
  • No more ƒ (Dynamic) markers on content pages

This represents a massive performance improvement. The homepage, general pages, service pages, and project pages are now pre-rendered at build time and served as static HTML. Product pages are also statically generated for their base URLs, with dynamic rendering only happening when users interact with variant filters and add search parameters to the URL.

Through this refactoring process, I learned several important lessons about Next.js App Router performance optimization:

Making searchParams async doesn't make your route static. The Next.js 15/16 async API change and the static generation opt-out are two separate concerns. Awaiting searchParams is required — but the route is still dynamic. You need route separation to restore static generation.

force-dynamic is a last resort, not a fix. If you're using it to silence searchParams build errors, you've accepted dynamic rendering for your entire route. Route separation is almost always the correct solution — it gives you static generation for 90% of your pages while preserving dynamic behavior exactly where it's needed.

searchParams has a global impact. The moment you accept searchParams in a page component, that entire route becomes ineligible for static generation. This isn't a bug—it's by design. Search parameters are request-time values, so Next.js must render the page dynamically to access them. Be very intentional about which pages truly need this functionality.

The 90/10 rule applies to routing. Don't let 10% of your pages that need dynamic behavior force the other 90% into dynamic rendering. Separate routes based on rendering requirements, not just on content organization.

Route separation improves performance without breaking functionality. You can still use generateStaticParams with routes that accept searchParams. The base URL gets statically generated, and only the query parameter variations render dynamically.

TypeScript strictness catches architectural issues. When I removed searchParams from the catch-all route, TypeScript immediately flagged every place where the interface had changed. This forced me to consciously update each component, preventing runtime errors from mismatched prop expectations.

The export path mismatch error reveals route conflicts. If you see "The provided export path doesn't match the page" during build, it means multiple routes are trying to generate the same static path. The solution is to ensure each route has exclusive ownership of its paths.

This refactoring took a few hours of careful implementation and testing, but the performance gains are substantial. Static generation means faster page loads, better SEO crawling, reduced server costs, and improved user experience for the vast majority of site visitors.

If your Next.js application uses a catch-all route with searchParams, audit which pages actually need that functionality. You might find, as I did, that a simple route separation unlocks significant performance improvements.

Once your routes are correctly separated, the next architectural decision is caching strategy. Static generation handles the rendering layer — for data freshness on your dynamic routes, look at unstable_cache and ISR revalidation. How to Speed Up Your Payload CMS Site With unstable_cache →

Thanks, Matija

📄View markdown version
0

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

How to Pass searchParams in Payload CMS Blocks Using Next.js 15.4+ Server Components
How to Pass searchParams in Payload CMS Blocks Using Next.js 15.4+ Server Components

6th August 2025

How to set-up livePreview in Payload with Nextjs's draftMode
How to set-up livePreview in Payload with Nextjs's draftMode

17th December 2025

How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams
How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams

15th August 2025

Table of Contents

  • Why searchParams Disables Static Generation
  • Next.js 15/16: searchParams Is Now Async
  • The Problem: searchParams Forces Dynamic Rendering on All Pages
  • Understanding the Static Generation Constraint
  • Step 1: Analyzing Which Pages Actually Need searchParams
  • Step 2: Creating a Dedicated Dynamic Route for Products
  • Step 3: Removing searchParams from the Catch-All Route
  • Step 4: Updating Static Path Generation
  • Step 5: Fixing the Homepage Implementation
  • Step 6: Updating Component Interfaces
  • Step 7: Resolving TypeScript and Build Errors
On this page:
  • Why searchParams Disables Static Generation
  • Next.js 15/16: searchParams Is Now Async
  • The Problem: searchParams Forces Dynamic Rendering on All Pages
  • Understanding the Static Generation Constraint
  • Step 1: Analyzing Which Pages Actually Need searchParams
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Payload CMS

    • Migration
    • Pricing

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved