My approach to building full-stack web apps with Next.js

This isn’t a “best practices” guide, this is the exact architecture I use when building my projects. It’s an opinionated approach that just works for everything i build.

7 min read · 6 days ago

Tobi OjoTobi Ojo
My approach to building full-stack web apps with Next.js

This article doesn’t cover everything in exhaustive detail, and it’s not a one-size-fits-all solution. Different projects have different needs, but this is the foundation I use for most of what I build.

How it all fits together

Here is what i’ll cover in this guide:

  1. Static files and custom css styles
  2. Animations and page transitions
  3. Routing and layout groups
  4. The “Page” suffix and component architecture
  5. Actions, Queries and Forms
  6. Next.js cache
  7. Background jobs

Static files and custom css styles

This includes things like the assets, Opengraph image, Twitter image, favicon, fonts. I like to get the boring stuff out of the way early. For favicons, I design a 32x32 image/SVG in Figma, export it, and add it to the app/ folder as icon.png. Next.js automatically detects this naming convention and renders it as a favicon.

Next, I add OpenGraph and Twitter images to the root layout metadata, placing the image files themselves in the public/ folder. The metadata config looks something like this:

typescript
1export const metadata: Metadata = {
2  title: {
3    template: `%s | My App`,
4    default: APP_NAME,
5  },
6  description: APP_DESC,
7  metadataBase: new URL(SERVER_URL),
8  openGraph: {
9    title: {
10      template: `%s | My App`,
11      default: "App",
12    },
13    images: [
14      {
15        url: "https://myapp.com/og.jpg",
16        width: 1200,
17        height: 630,
18      },
19    ],
20    description: APP_DESC,
21    siteName: "App",
22  },
23  twitter: {
24    title: {
25      template: `%s | My App`,
26      default: "App",
27    },
28    description: APP_DESC,
29    card: "summary_large_image",
30    images: ["https://myapp.com/og.jpg"],
31  },
32};

Next is the fonts, and for that i use next/font which automatically optimizes your fonts (including custom fonts) and removes external network requests for improved performance.

For local assets like background videos in the public/ folder, I use Handbrake for compression. Heavy assets are the #1 cause of slow websites, so I compress videos from like 10MB down to around 1MB with virtually no quality loss.

If i have any custom css styles, this is the part where i also do that. If i need custom styling for certain texts like say titles or container with a max width, i create those in the global.css file. For example:

css
1.article-title {
2  font-size: clamp(1.75rem, 5vw, 3rem);
3  line-height: 1.2;
4  font-weight: 700;
5}

Animations and page transitions

For motion, GSAP is my go-to for both complex animation timelines and subtle micro-interactions. I’ve always used it since i started coding in 2022 and never ran into issues. I use Lenis too, for smooth scrolling (only on creative websites or landing pages). No one wants smooth scrolling or fancy animations in their dashboard.

For page transitions, i am still experimenting here. I currently use the Next Transition Router but would love to try out the View Transition API in my new project.

Routing and layout groups

Organization is a form of documentation. I heavily utilize Next.js Route Groups () to separate concerns without affecting the URL structure. And how do i approach this? I create a (root) group with its own layout. This would include routes like Home, About, Privacy policy etc. This type of organization allows you to create separate groups for (auth) or (admin) with entirely different layouts and navigation systems, avoiding "Conditional Rendering Hell" in a single root layout.

The “Page” suffix and component architecture

I name my main route with a -page suffix and treat them strictly as Server Components. The actual UI logic lives in a child component (e.g., RouteContent.tsx).

Why? This allows the page.tsx to handle Metadata types and async data fetching comfortably. I fetch data on the server and pass it as props to the child. If the child needs hooks, it becomes a Client Component, but the “Page” remains a clean, server-side entry point. This separation saves us a ton of stress later.

typescript
1import { Metadata } from "next";
2import SignupForm from "./SignupForm";
3import { getData } from "./query.ts";
4
5export const metadata: Metadata = {
6  title: "Signup | My App",
7};
8
9export default async function SignupPage() {
10  // Fetch on the server
11  const data = await getData(); 
12
13  // Pass data as a prop to the child
14  return <SignupForm data={data} />;
15}

the signup form with the hooks

typescript
1"use client";
2import {useState} from "React"
3
4export default function SignupForm({ data}: { data: any }) {
5const [whatever, setWhatever] = useState("")
6
7  return (
8    <>
9      {/* Whatever props we passed down are then used in the component */}
10    </>
11  );
12}

If a component is only used on one route (like a SignupForm), it lives inside that route’s folder—not the global /components folder. I personally feel this gives more clarity to anyone reading the codebase.

Actions, Queries and Forms

I’ve replaced API routes with Server Actions for almost everything. For queries, I create a query.ts file which contains all db queries.

For mutations, I organize by resource in an actions folder—product.actions.ts, user.actions.ts, and so on.

Though you can call queries from server actions, i don’t do that. Since i learnt they are POST requests under the hood, i’ve always separated them, personal preference.

I keep a constants.ts file for mostly form default values and a validators.ts file for Zod schemas. Using Zod with React Hook Form gives me validation on both the client and the server, just the usual stuff.

Next.js cache

Caching is one of the most misunderstood part of Next.js. I’m currently still using unstable_cache, with Next.js 15 with timed revalidation but moving toward the "use cache" directive in Next.js 16. This is because i like to understand things fully before jumping in. Every few months, you think you finally understand how caching works in Next.js and then the next update comes and they change everything again. So it’s better i just stick to what works for me until i learn the new stuff.

There are two important things developers need to understand about Next.js Caching to avoid being confused:

  1. Dev mode vs. Prod mode: Next is lenient in development. If your cache feels “broken,” it’s likely because you aren’t testing in a build/start environment. In dev mode, Next.js prioritizes “freshness.” It will often re-run functions to ensure you see your latest code changes. If you just stay in npm run dev, you'll see it rerunning and assume it's broken when it probably isn’t. If you want to know if your cache is actually working:
    • Add a console.log inside your data fetch.
    • Run npm run build and npm run start
    • Refresh the page. If the log only appears once, it’s cached
  2. Network vs. Database: TanStack Query and Next.js both use caching, but they work at completely different layers of your application. TanStack Query operates on the client side. When it caches data, it prevents network requests entirely. The first time you fetch data, you'll see a 200 status in Chrome DevTools. On subsequent fetches, TanStack Query serves the cached data directly from browser memory, meaning no network request happens at all. You won't even see a new entry in the Network tab. Next.js caching on the other hand works on the server side. It prevents database queries, not network requests. Here's the important part: you'll still see 200 status codes in your browser's DevTools Network tab even when Next.js is serving cached content. That's because your browser is still making an HTTP request to the Next.js server, the server just isn't querying the database anymore. It's returning a pre-rendered, cached result instead.

When you check Chrome DevTools and see 200 responses, don't assume Next.js caching isn't working. The network request from your browser to the server still happens (hence the 200), but behind the scenes, Next.js is saving you expensive database operations by serving cached results. It's a different kind of caching than what you're used to with client-side tools like TanStack Query.

When cacheComponents are enabled, the recommended pattern seems to be using the "use cache" directive with cache tags inside queries and using the revalidateTag inside mutations.

After reading the docs (particularly the sections on constraints), I decided the best pattern for me is using the "use cache" directive at the function level.

typescript
1export async function getProducts() {
2  "use cache"; 
3  // You can add 'cacheTag' here which we then revalidate in the associated mutation funtion
4  const products = await db.product.findMany();
5  return products;
6}

Background jobs

I read an article once where the guy said the Webhooks should be dead simple. Basically you shouldn’t be doing any business logic inside a webhook. Just acknowledge it with a 200 OK and get out. And for this, i use Inngest for the actual heavy lifting. It’s easy to set up, and the local/production dashboards make monitoring events easy.

This is what an event on inngest looks like:

inngest event image
Inngest dashboard

Conclusion

This architecture isn’t about following every new Next.js feature blindly, it’s about creating a predictable environment where you aren’t surprised by a “use client” error or a broken cache in production.

By keeping my webhooks “dumb,” my page logic co-located, and my server/client boundaries strictly defined through the “Page Suffix” pattern, I’ve found a flow that handles both creative work and heavy data lifting without the usual technical debt.

Architecture is a living thing. This is where I’ve landed for 2026, but as the ecosystem evolves I’ll keep refining. Thanks for reading!

WRITINGS
1/3