Practical Insights: Building with Next.js 16 and Vitest

Software development involves more than just writing logic. Selecting the right tools, frameworks, and infrastructure is essential for a project's success.
I recently completed a small-to-medium full-stack project using Next.js 16, and I wanted to share some of the practical lessons I learned along the way. This post focuses on:
My goal is to share these observations simply and directly, in hopes they might offer a helpful perspective for your own work.
In this project, I aimed for a multi-layered caching strategy to balance performance and data freshness.
I used Redis as a shared data store to handle global state that persists across different instances. It proved to be a reliable way to reduce database load for frequently accessed data.
While use cache is a powerful tool in server actions, I personally prefer using Redis for certain use cases. This approach allows me to keep the data and service layers clearly separated without spreading directives throughout the codebase. It also provides a more centralized way to control and manage the cached data.
With the latest updates, caching has become more integrated into the component lifecycle.
cache function helps in memoizing data fetching at the component level.use cache directive allows for more granular control over what gets cached on the server, making the transition between static and dynamic content much smoother.For those interested in a deeper dive, I have written a detailed post specifically about Cache in Next.js 16.
To keep the application stable and secure, I implemented a rate-limiting layer. Since this is a broad topic, I have shared a detailed breakdown of the logic and configuration in a separate blog post. For this specific project, the focus was simply on ensuring a fair usage policy for all users.
For testing, I migrated to Vitest. I found it to be significantly faster and more intuitive within a Vite-based ecosystem. Specifically, I want to highlight a few features that improved my workflow:
Vitest makes mocking straightforward. In the example below, I’m mocking the Resend library and the Next.js after function (often used for background tasks or cron jobs):
const sendMock = vi.fn(); // Mocking the Resend library vi.mock("resend", () => ({ Resend: class { emails = { send: sendMock }; }, })); // Mocking the Next.js 'after' function to execute immediately during tests vi.mock("next/server", async () => { const actual = await vi.importActual<any>("next/server"); return { ...actual, after: (fn: any) => fn(), }; });
To keep the feedback loop fast, I prefer to separate unit tests from integration tests. I use a naming convention where files end in .unit.test.ts or .integration.test.ts.
To support this, I configured Vitest projects in vitest.config.ts:
export default defineConfig({ ...sharedConfig, // Spread it here for general vitest tasks test: { globals: true, coverage: { provider: "v8", reporter: ["text", "html"], }, projects: [ { ...sharedConfig, test: { name: 'unit', environment: 'node', root: __dirname, // Explicitly set the root include: ['src/**/_test/*.unit.test.{ts,tsx}'], setupFiles: ["./vitest.setup.ts"], }, }, { ...sharedConfig, test: { name: 'integration', environment: 'node', root: __dirname, include: ['src/**/_test/*.integration.test.{ts,tsx}'], setupFiles: ['./vitest.setup.ts'], // Fixed: matches your setup file name }, }, ], }, });
Finally, I added specific scripts to my package.json to allow running these suites independently:
"scripts": { ... "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration", }
For projects with high-frequency content updates, using a CMS like Strapi or Payload is often the better approach. However, for this project, I chose MDX. Since my requirements weren't overly complex, I wanted to avoid adding unnecessary layers to the architecture.
MDX is a powerful format that allows you to use JSX components and custom styling directly within Markdown files. It integrates seamlessly with Next.js 16 and Tailwind CSS, providing a great balance between writing speed and design flexibility.
To get started, update your next.config.mjs to recognize MDX files:
//next.config.mjs import createMDX from '@next/mdx' /** @type {import('next').NextConfig} */ const nextConfig = { // Configure `pageExtensions` to include markdown and MDX files pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], // Optionally, add any other Next.js config below } const withMDX = createMDX({ // Add markdown plugins here, as desired }) // Merge MDX config with Next.js config export default withMDX(nextConfig)
You can define a mdx-components.tsx file in the root of your project to map standard HTML tags to your own styled components. For example, I created a custom Wrapper to ensure certain elements are centered vertically:
mdx-components.tsx export function useMDXComponents(components: MDXComponents): MDXComponents { return { // Customizing the standard h1 tag with Tailwind h1: ({ children }) => ( <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mb-4"> {children} </h1> ), // A custom Wrapper component for layout control Wrapper: ({ children }) => ( <div className="flex justify-center w-full">{children}</div> ), ...components, };
Now, you can use those custom components directly inside your .mdx files alongside standard Markdown syntax:
<Wrapper> <img src="/path-to-image.jpg" alt="Project Screenshot" style={{ maxWidth: '100%', height: 'auto' }} /> </Wrapper>
This is an example of combining a custom React component with standard text.
I am a strong advocate for deeply understanding core concepts, as this knowledge often helps prevent hidden bugs. Closures are a perfect example; if we aren't careful about how variables are captured, we can easily end up working with stale data.
A closure "captures" variables from its surrounding scope at the time it is created. While this is a powerful feature, it can lead to issues in React when a function is defined during one render but executed after the state has changed.
In the example below, the combination of changePage and startTransition creates a closure. The posts variable is captured when the function is defined. Even though the state updates, the logic inside the transition might still refer to the old version of posts.
const [posts, setPosts] = useState([]); const [isPending, startTransition] = useTransition(); ... const changePage = () => { if (postsCount === 0 || initFetch.current) return; startTransition(async () => { const fetchedPosts = await getPosts(page + 1); startTransition(() => { setPage((prev) => prev + 1); setPosts((prev) => [...prev, ...fetchedPosts]); // Warning: 'posts' here refers to the value from the previous render. // If the state has updated in the meantime, this check may be inaccurate. if (posts.length + fetchedPosts.length >= +postsCount) { setHasMore(false); } }); }); };
The Solution: Functional Updates
To fix this in React, we should perform the logic inside the setPosts updater function. This ensures we are always working with the most recent state, as React provides the current value as an argument (prev).
const changePage = () => { if (postsCount === 0 || initFetch.current) return; startTransition(async () => { const fetchedPosts = await getPosts(page + 1); startTransition(() => { setPage((prev) => prev + 1); setPosts((prev) => { const updated = [...prev, ...fetchedPosts]; if (updated.length >= +postsCount) { setHasMore(false); } return updated; }); }); }); };
Using this pattern keeps our logic predictable and ensures our application state remains consistent, even within complex transitions.
In conclusion, balancing modern tools like Next.js 16 and React 19 with established solutions like Redis is essential for building scalable applications. By focusing on a multi-layered caching strategy, structured testing with Vitest, and choosing simpler content management tools like MDX, we can maintain a clean architecture without over-engineering the solution.
Each project has its own unique needs, and the choice of technology should always depend on the specific requirements—whether that is performance, developer experience, or long-term maintainability.
As always, I may have missed some points or nuances, so if you have any questions or suggestions, please feel free to share them with me. If you have found a better approach to caching in Next.js or a more efficient testing workflow, I’d love to hear about it. I’m always open to learning and improving my knowledge alongside the community. Stay humble and keep learning!