Let’s say you’re a React developer. You love building stuff — but juggling routing, API endpoints and SEO - feels like cooking with too many stoves at once. What if you had just one pan that does it all? That’s where Next.js comes in. It’s packed with powerful new features that make building modern web apps faster and more efficient than ever. In this article, I’ll give you a quick yet comprehensive overview of what’s new and how it fits into your development workflow — all by building a simple and quick Recipe Sharing App.
If you prefer watching over reading, I’ve also created a video tutorial with animated explanations and a live coding demo. Feel free to check it out below before diving into the details:
If you prefer reading over watching video, let's continue. Next.js is a React framework that turns you into a full-stack developer — even if you don’t want to be. And in this article, we’re building a Recipe Sharing App to explore how Next.js works and why it makes React - feel complete.
Installation
We’ll start with a blank kitchen. Open up your terminal and type:
npx create-next-app@latest recipe-app
Go with the default options — no TypeScript, no strict mode — just hit enter a few times. Once it’s done, head into the folder cd recipe-app
and start the development server:
npm run dev
Your app is live on localhost:3000
.
App Router & File-based Routing
That default page you see? That’s coming from a file called app/page.js
. Yes — in Next.js, routing is file-based. If you create a file, you create a page.
So let’s make our homepage say something like: "Welcome to Recipe App!" — feel free to add some Tailwind CSS classes if you want it to look slightly less boring.
// app/page.js
export default function Page() {
return <h1 className="text-center font-bold p-20">Welcome to Recipe App!</h1>;
}
Now we need an "Add Recipe" page. Just make a new folder named add
, and inside it, a file called page.js
. Return any JSX you want — maybe just a form placeholder for now.
// app/add/page.js
export default function Add() {
return <form className="text-center font-bold p-20">Add recipe</form>;
}
Visit /add
in your browser. Next.js has already mapped the route. That’s it. No router setup! No config files! You just build!
Let’s make it dynamic. Say we want a page for each recipe — like /pizza
or /sushi
. We’ll create a folder called [recipeId]
with a page.js
file inside. Yes, square brackets. They turn it into a dynamic route.
Now when someone visits /burger
, the component inside this page will receive a params
object where recipeId
is 'burger'. You can fetch the recipe from a database, or just mock it for now. It works like URL magic!
// app/[recipeId]/page.js
export default async function Recipe({ params }) {
const { recipeId } = await params;
return (
<h1 className="text-center font-bold p-20">{recipeId}</h1>
);
}
Layouts & SEO
But our app needs a consistent layout — maybe a navbar that shows up on every page. That’s what app/layout.js
file is for. This file wraps your entire app.
Add a nav
section with two links — one to /
, and one to /add
. But don’t use regular anchor tags — use Next.js’s built-in <Link>
component instead. It enables client-side navigation without full page reloads. Fast and smooth, like flipping through a cookbook.
// app/layout.js
import Link from "next/link";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<nav className="text-sm flex justify-center space-x-8 p-20">
<Link href="/">Home</Link>
<Link href="/add">Add Recipe</Link>
</nav>
{children}
</body>
</html>
);
}
While we’re in layout.js, let’s do some SEO. Next.js lets you export a metadata
object. Just add it with page title and description:
// app/layout.js
export const metadata = {
title: "Recipe World",
description: "Discover and share recipes from around the world.",
};
Each page can have its own SEO data. In dynamic routes like [recipeId]/page.js
, use the generateMetadata
function. Grab the recipeId from params, and return a custom title and description based on it. Now every page is SEO optimised without any extra plugin.
// app/[recipeId]/page.js
export async function generateMetadata({ params }) {
const { recipeId } = await params;
return {
title: `${recipeId} title`,
description: `${recipeId} description goes here`,
};
}
Server vs Client Components
Time to talk rendering. In Next.js, there are two types of components: Server and Client. By default, everything inside the app
folder is a Server Component. That means it's rendered on the server, then sent as HTML. Fast and SEO-friendly.
But if you need interactivity — like a form — just add the directive "use client"
at the top of the file. Now you can use hooks like useState
, useEFfect
etc. Think of it like this: the server cooks the meal, and the client sprinkles the garnish!
// app/add/page.js
"use client";
import { useState } from "react";
export default function Add() {
const [title, setTitle] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
console.log(`Form submitted with recipe title: ${title}`);
};
return (
<div>
<form
onSubmit={handleSubmit}
className="text-center font-bold p-20 flex flex-col gap-10"
>
<input
className="border border-slate-800 outline-0 px-4 py-2 rounded"
placeholder="Enter recipe title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
type="submit"
value="Submit"
className="bg-gray-800 py-2 rounded"
/>
</form>
</div>
);
}
Data Fetching: SSR, SSG, ISR
Let’s fetch some data. In app/page.js
, make your component async. Inside it, fetch some recipes from a fake API — or just return a hardcoded array. Because it’s a Server Component, the fetch happens on the server. The page is pre-rendered and delivered with data.
// app/page.js
import Link from "next/link";
export default async function Home() {
const res = await fetch("https://dummyjson.com/recipes");
const recipes = await res.json();
return (
<ol className="ml-auto text-center">
{recipes.recipes.map((recipe) => (
<li key={recipe.id}>
<Link href={`/${recipe.id}`}>{recipe.name}</Link>
</li>
))}
</ol>
);
}
For dynamic pages like [recipeId]
, use the params
object to get the ID and fetch that specific recipe.
// app/[recipeId]/page.js
export default async function Recipe({ params }) {
const { recipeId } = await params;
const res = await fetch(`https://dummyjson.com/recipes/${recipeId}`);
const recipe = await res.json();
return (
<div className="flex flex-col justify-center items-center">
<h1 className="text-center font-bold p-5">{recipe.name}</h1>
</div>
);
}
Everything renders ahead of time in build-time — or "on request" — depending on how you configure it. Speaking of which, let’s talk SSG, SSR, and ISR techniques - Three ways to render a page.
Static Site Generation (SSG) means, the page is built at build time. Perfect for your homepage — just fetch the data once and ship the HTML.
Server-Side Rendering (SSR) means, it’s rendered on every request. Use this for pages that need to stay fresh — like individual recipes.
But there's a hybrid mode called Incremental Static Regeneration (ISR). It works like SSG, but regenerates in the background after a set time. Just add a revalidate
option to your fetch logic. It keeps things fast — and up to date.
// app/page.js
const res = await fetch("https://dummyjson.com/recipes", {
next: {
revalidate: 60, // seconds
},
});
const recipes = await res.json();
For dynamic routes, if you already know which pages to pre-build, export a generateStaticParams()
function. Return an array of known recipe IDs. Now those pages will be statically generated too.
// app/[recipeId]/page.js
export async function generateStaticParams() {
const recipes = await fetch("https://dummyjson.com/recipes").then((res) =>
res.json(),
);
return recipes.recipes.map((recipe) => ({
recipeId: recipe.id.toString(),
}));
}
API Routes in App Directory
Let’s accept user input. In app/api/recipes/route.js
file, export a POST handler that takes "form data" and responds with a success message. That’s your API endpoint — right there in the app folder.
// app/api/recipes/route.js
import { NextResponse } from "next/server";
export async function POST(request) {
const { title } = await request.json();
// ← here you could insert into a database, etc.
console.log("New recipe title:", title);
return NextResponse.json(
{ message: "Recipe added successfully!", recipe: { title } },
{ status: 201 },
);
}
On the Add Recipe page, when the user submits the form, send a fetch
request to ('/api/recipes') route with the form data. You now have full-stack functionality without writing any Express JS code.
Styling
Time to make things look good. Next.js supports any styling method, but Tailwind CSS makes it stupid easy. Add some classes like text-center
, bg-gray-100
, p-4
etc. You can also create CSS Modules or edit globals.css
if you want more control.
Loading & Streaming
Let’s polish the user experience with a loading state. Inside [recipeId]
folder, create a file called loading.js
and return a skeleton loader:
// app/[recipeId]/loading.js
export default function Loading() {
return (
<div className="max-w-lg m-auto">
<div className={`animate-pulse space-y-4`}>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</div>
);
}
When the page is loading, Next.js will automatically show this.
Next.js also supports streaming — meaning parts of the page can show up while others are still rendering. You can wrap components in <Suspense>
boundary with a fallback for loading states.
// app/page.js
import { Suspense } from "react";
import dynamic from "next/dynamic";
// Dynamically import any HeavyComponent component with suspense support
const HeavyComponent = dynamic(() => import("./components/heavy-component"), {
suspense: true,
});
export default async function Home() {
return (
<div>
<h1>Recipe App</h1>
{/* Suspense boundary with a fallback */}
<Suspense
fallback={
<h1 className="text-center font-bold pb-20">
Loading user profile...
</h1>
}
>
<HeavyComponent />
</Suspense>
</div>
);
}
In this example, the page and heading load instantly, while HeavyComponent
streams in once it’s ready.
Error handling
Now let’s handle errors. If a recipe doesn't exist, you can throw a notFound()
method. Next.js will show the not-found.js
page in your app
folder.
// app/[recipeId]/page.js
export default async function Recipe({ params }) {
const { recipeId } = await params;
// throw new Error("Something went wrong");
const res = await fetch(`https://dummyjson.com/recipes/${recipeId}`);
const recipe = await res.json();
if (!recipe.id) notFound();
return (
<div className="flex flex-col justify-center items-center">
<Image alt={recipe.name} src={recipe.image} width={200} height={200} />
<h1 className="text-center font-bold p-5">{recipe.name}</h1>
</div>
);
}
// app/not-found.js
export default async function NotFound() {
return (
<h1 className="text-center p-20 font-bold">
The requested resource was not found!
</h1>
);
}
If you want route-specific error handling, drop an error.js
file inside [recipeId]
folder. It catches any rendering errors on that route. No white screens. No crashes.
// app/[recipeId]/error.js
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
export default function Error({ error, reset }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="text-center text-sm">
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}
Image & Font optimization
Next.js is optimised by default. Use the <Image>
component for automatic image optimisation. It serves responsive images with lazy loading.
// app/[recipeId]/page.js
import Image from "next/image";
export default async function Recipe({ params }) {
// ... fetch recipe code
return (
<div className="flex flex-col justify-center items-center">
<Image alt={recipe.name} src={recipe.image} width={200} height={200} />
<h1 className="text-center font-bold p-5">{recipe.name}</h1>
</div>
);
}
For fonts, import them in layout.js
using the next/font
utility. You can see the font usage in the default layout.js
file already!
Route groups & Parallel Routes
Let’s level up the routing. Create a folder like (admin)/dashboard/page.js
. This groups it logically but doesn’t affect the URL — so the path is still /dashboard
, but now you can separate admin-only stuff. These are route groups.
Want parallel routes? Inside the dashboard
folder, create folders like @myRecipes
and @favorites
, each with their own page.js
file. Both will render side-by-side inside a shared layout. It’s like two sections of your dashboard, loading independently.
// app/(admin)/dashboard/layout.js
export default function AdminLayout({ children, favorites, myRecipes }) {
return (
<div>
{children}
<div className="grid grid-cols-2 gap-10 px-10">
{favorites}
{myRecipes}
</div>
</div>
);
}
Authentication with NextAuth
Time to lock it down. Use Auth.js
— formerly known as NextAuth — to set up authentication. Add Google login, and protect routes like /add
or /admin
by checking if the user is signed in. You now have a secure recipe app.
Middleware
Want more power? Use Middleware. Create a middleware.js
file to intercept requests, redirect users, rewrite URLs, or block access. You can also add internationalization with built-in i18n support to make your app multilingual.
// ./middleware.js
export { auth as middleware } from "@/auth";
import Google from "next-auth/providers/google";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Google],
});
export function middleware(request) {
return new Response("Access Denied", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
});
}
// matching paths
export const config = {
matcher: "/about/:path*",
};
Deploy to Vercel
Finally — let’s deploy. Push your code to GitHub, then connect it to Vercel - the company who built Next.js. Click deploy. You’re live in seconds. You can also run npm run build
and host it anywhere — Netlify, VPS, whatever you want.
Wrap-up
And that’s a wrap. We built a full Recipe App with routing, layouts, API routes, SEO, authentication, and advanced rendering — all using just one framework: Next.js.
The full code is on GitHub. Check out the Next.js official documentation to dive deeper into this powerful React Framework.
Top comments (7)
been cool seeing steady progress with next.js - helps me not stress about all the moving parts. you think habits or just showing up every day is what actually gets people from beginner to feeling solid with this stuff?
Thanks so much! I totally agree. Personally, I think showing up consistently builds habits over time. Even 20-30 minutes a day compounds fast, especially with tools like Next.js. Curious what’s helped you feel more confident so far?
Love how Next.js handles everything in one place, makes full-stack so much less stressful for me
What’s the biggest pain point this approach solved for you in your own projects?
I was already good at React and doing just fine! Before Next.js, everything I built was client-side. But Next.js opened the door to the server side as well. This shift has completely changed the mindset of front-end engineers and I’m no exception. Now, you can render parts of your page on the server and parts on the client - how beautiful is that? You can even cache almost everything right out of the box! Isn’t that cool?
Thanks for sharing.
Next.js is a modern framework and the article also teaches in modern style.
Thanks for sharing.
Thanks a lot. Please stay tuned for more articles.