Blog

Next.js tips and tricks I actually use

6 min read
nextjsreacttypescripttips

I've been building with Next.js for a few years now: this portfolio, client platforms, side projects. These are the tips I find myself reaching for on every project, and the gotchas that cost me an afternoon each so they don't have to cost you one.

Everything below targets the App Router.

1. params and searchParams are Promises now

Since Next.js 15, dynamic route props are async. If you're migrating older code (or pasting from an old tutorial), this is the first thing that breaks:

interface PostPageProps {
  params: Promise<{ slug: string }>;
}
 
export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params; // <- await it
  const post = getPostBySlug(slug);
  // ...
}

The same applies to searchParams and to the params argument of generateMetadata. Type them as Promise<...> and await them. The old synchronous access logs warnings today and will be removed in a future major.

2. Kill request waterfalls with Promise.all

The most common App Router performance bug I see isn't a missing cache. It's sequential awaits that should be parallel:

// Slow: each request waits for the previous one
const user = await getUser(id);
const posts = await getPosts(id);
const stats = await getStats(id);
 
// Fast: all three in flight at once
const [user, posts, stats] = await Promise.all([
  getUser(id),
  getPosts(id),
  getStats(id),
]);

If the calls don't depend on each other's results, they shouldn't wait for each other. On a page with three 100ms data sources, that's the difference between 300ms and 100ms of server time, for free.

3. Metadata merging is shallow, and it will bite you

Nested layouts and pages merge their metadata exports, but the merge is shallow, per top-level key. I hit this wiring up a blog: the layout declared the RSS feed, the post page declared a canonical URL, and the RSS link silently vanished.

// app/blogs/layout.tsx
export const metadata = {
  alternates: {
    types: { "application/rss+xml": "https://lencho.dev/blogs/feed.xml" },
  },
};
 
// app/blogs/[slug]/page.tsx
export const metadata = {
  alternates: { canonical: "https://lencho.dev/blogs/my-post" },
  // ^ this REPLACES the layout's entire `alternates` object.
  //   The RSS <link> is gone from this page's head.
};

The fix is boring but necessary: any top-level key you set at the page level must carry everything you want in it. Check your rendered <head> after adding page-level metadata. Don't assume the layout's version survived.

4. Prerender dynamic routes with generateStaticParams

A dynamic segment like [slug] renders on-demand by default. If you know the slugs at build time, tell Next about them and every page ships as static HTML:

export function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

The build output tells you whether it worked. Look for the (SSG) marker:

 /blogs/[slug]
 /blogs/my-first-post

If you expected and got ƒ (dynamic), something in the page opted it out: usually an un-cached fetch, or reading headers()/cookies(). The build output is the fastest debugging tool Next ships; read it every time.

5. Stream slow parts with Suspense instead of blocking the page

If one data source is slow, don't make the whole page wait. Render the shell immediately and stream the slow part in:

import { Suspense } from "react";
 
export default function Dashboard() {
  return (
    <main>
      <Header />               {/* instant */}
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />          {/* async server component, streams in */}
      </Suspense>
    </main>
  );
}

SlowStats is an async server component; React streams its HTML when the data resolves. A route-level loading.tsx is the same mechanism with zero setup. Component-level Suspense is better when only part of the page is slow, because visitors get real content instantly instead of a full-page skeleton.

6. Route handlers can be static too

Route handlers aren't only for dynamic APIs. A GET that depends on nothing per-request can be prerendered at build time. I use this for RSS feeds:

// app/blogs/feed.xml/route.ts
export const dynamic = "force-static";
 
export function GET() {
  const posts = getAllPosts();
  return new Response(buildRssXml(posts), {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}

Zero server work at request time; it's a file on the CDN. The same trick works for sitemap.xml-style endpoints, manifest files, or any computed-but-stable response.

7. Validate at the boundary, fail at build time

External data (CMS responses, frontmatter, environment config) should be validated where it enters your system, not trusted and passed along. With Zod, invalid content becomes a build failure instead of a production surprise:

const frontmatterSchema = z.object({
  title: z.string().min(1),
  description: z.string().min(1),
  date: z.coerce.date(),
  draft: z.boolean().default(false),
});
 
const parsed = frontmatterSchema.safeParse(data);
if (!parsed.success) {
  // a bad post kills the build instead of silently shipping
  throw new Error(`Invalid frontmatter in ${file}: ${parsed.error.message}`);
}

Because SSG runs your data layer at build time, a thrown error here means the deploy fails loudly. That's a feature: the broken version never goes live.

8. Read the middleware matcher carefully

Middleware is the sharpest knife in the App Router drawer, and the matcher is where it cuts. A pattern like this one excludes dotted paths (files) from ever reaching your middleware:

export const config = {
  matcher: ["/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)"],
};

That exclusion is usually what you want for static assets, but it also means routes like /feed.xml silently bypass any rewrite or redirect logic you wrote. If a middleware-dependent URL "randomly" 404s while its sibling pages work, check whether the matcher is quietly skipping it. Test middleware behavior on the deployed URL shapes, not just in unit isolation; it's exactly the kind of failure the build output won't show you.

The pattern behind the tips

Most of these reduce to one habit: let the build do the work. Prerender what you can (generateStaticParams, static route handlers), fail early when content is wrong (schema validation), and read what the build output is telling you. The App Router rewards pushing work from request time to build time; every tip here is a variation on that theme.

Got a tip I missed or a war story about one of these? Tell me, or ask my site's AI assistant about how any of this is implemented here.