
A CRUD app is one of the best ways to learn full-stack web development because it covers the core lifecycle of data: create, read, update, and delete. If you can build a clean CRUD app, you already understand a huge portion of what powers dashboards, admin tools, internal workflows, and many everyday SaaS products.
Next.js is an especially strong fit for this kind of project because it combines frontend and backend patterns in one framework. With the App Router, Server Components, Server Actions, Route Handlers, built-in TypeScript support, and modern caching/revalidation tools, you can build a fast, maintainable app without stitching together too many separate systems. Next.js also makes it easier to progressively enhance forms, keep UI responsive, and invalidate fresh data after a mutation. (nextjs.org)
In this guide, we’ll build a simple CRUD app the modern Next.js way: starting with project setup, then adding data storage, and finally implementing the full read-create-update-delete flow with validation, error handling, caching, and deployment-minded polish.

CRUD stands for Create, Read, Update, and Delete—the four basic operations most apps need for managing records. A contacts app, task tracker, inventory system, note-taking tool, or simple content dashboard all use this pattern. The concept is simple, but the implementation teaches a lot: forms, database access, state management, validation, loading indicators, and secure server-side mutations.
Next.js fits CRUD apps well because it gives you a unified way to build both the UI and the server logic. In the App Router, you can render pages with React Server Components, handle forms with Server Actions, and use Route Handlers when you need an API-like endpoint. That means fewer moving parts and less glue code than many traditional frontend/backend splits. The framework also has built-in TypeScript support, which helps catch mistakes early and makes your data model easier to maintain as the app grows. (nextjs.org)
Another major advantage is caching and revalidation. CRUD apps often show a list of records, then mutate one record and need the list to refresh. Next.js supports on-demand invalidation with revalidatePath, revalidateTag, and updateTag, which lets you keep the app fast while still showing fresh data after changes. That is especially useful for apps where users expect immediate feedback after saving a form or deleting a record. (nextjs.org)
This makes Next.js a great choice for modern teams: it is flexible enough for small projects, but structured enough to scale into a serious production app. If you want a practical learning project that reflects real-world patterns, a CRUD app in Next.js is an excellent place to start. (nextjs.org)
Start by creating a new Next.js app with the official CLI. The create-next-app tool can initialize an App Router project and, by default, supports TypeScript, Tailwind CSS, linting, and other common setup choices. TypeScript is the recommended path for a CRUD app because it improves data consistency and makes form and database code easier to refactor safely. (nextjs.org)
A typical setup looks like this:
npx create-next-app@latest crud-appWhen prompted, choose:
TypeScript: Yes
App Router: Yes
Tailwind CSS: Yes if you want quick styling
ESLint or Biome for code quality
src/ directory: Yes if you prefer a cleaner source layout
Once the project is created, a simple App Router structure might look like this:
src/
app/
layout.tsx
page.tsx
globals.css
items/
page.tsx
loading.tsx
error.tsx
actions/
item-actions.ts
components/
ItemForm.tsx
ItemList.tsx
SubmitButton.tsx
lib/
db.ts
validators.ts
items.ts
types/
item.tsThis organization keeps related concerns separate:
app/ handles routes and route-level UI
components/ holds reusable UI pieces
lib/ contains database and utility logic
types/ stores shared TypeScript types
For a CRUD app, it is also helpful to think in terms of server boundaries. Keep database access and mutation logic on the server. Keep interactive form state in client components only when necessary. This gives you a cleaner, safer codebase and aligns with the App Router model. Next.js also supports loading states with loading.js and error boundaries with error.js, which you can use later for a polished user experience. (nextjs.org)
Before writing UI, decide how your data will be stored. For a small CRUD app, you have several good options, and the right choice depends on your goals.
If you are just prototyping, you can start with an in-memory array or static mock data file. This is fast and easy for learning, but data resets when the server restarts, so it is not suitable for real persistence.
A JSON file can be a simple step up for demos. It lets you persist records locally without introducing a full database. However, file-based writes can become fragile in serverless environments and do not scale well when multiple users are editing at once.
SQLite is a great choice for small-to-medium CRUD apps. It is lightweight, easy to set up, and works well for local development or small deployments. It is especially appealing if you want the app to feel “real” without the complexity of a larger database system.
PostgreSQL is the best general-purpose choice for production CRUD apps. It is reliable, widely supported, and pairs well with ORMs or query builders. If you expect growth, relational data, or multiple tables, PostgreSQL is usually the safest long-term option.
A hosted database service can simplify backups, scaling, and deployment. This is often the easiest path for teams or apps that need cloud hosting from day one.
For the data model itself, keep it simple. A typical record might look like this:
type Item = {
id: string
title: string
description?: string
createdAt: string
updatedAt: string
}You can use this same shape across your UI, form validation, and database queries. That consistency is one of the biggest advantages of TypeScript. If you are using server-side rendering and cached data, Next.js lets you tag and invalidate those records cleanly later with revalidateTag, updateTag, or revalidatePath. (nextjs.org)
If you are unsure which storage option to choose, a practical rule is:
Learning/demo: mock data or JSON
Small app: SQLite
Production app: PostgreSQL or a hosted Postgres-compatible database
The read view is the heart of most CRUD apps because it is where users see the current data. A simple list is fine for small apps, while a table works better when you need columns like name, status, date, and actions.
A clean read view should support three important states:
Loading state
Show a skeleton, spinner, or placeholder while data loads. Next.js App Router can display a route-level loading UI with loading.tsx, which gives users immediate feedback during navigation. (nextjs.org)
Empty state
If there are no records, do not show a blank table. Instead, explain that no items exist yet and provide a clear call to action like “Create your first item.”
Data state
Render the list or table with actions like Edit and Delete.
A simple server component page can fetch data and pass it into a presentational component. That keeps the page lean and makes the UI easier to test. If your data comes from a cached source, remember that a mutation may require revalidation so the list stays fresh. Next.js recommends tag-based invalidation when possible because it is more precise than invalidating an entire path. (nextjs.org)
A good read view also supports accessibility:
Use semantic table markup when displaying structured rows
Make action buttons descriptive
Keep focus states visible
Make empty-state text helpful, not vague
If you want to make the page feel more polished, add:
search or filtering
sort controls
pagination for larger datasets
timestamps like “updated 2 hours ago”
But for a first CRUD app, focus on clarity. The main goal is to make the current records easy to scan and easy to act on. That is what makes the “read” part of CRUD feel useful instead of just functional.
Creating records is where the app starts to feel interactive. In Next.js, forms integrate especially well with Server Actions, which are designed to handle form submissions on the server. React allows a <form> to invoke a Server Action through the action attribute, and the function receives FormData automatically. (nextjs.org)
A strong create form should include:
clear labels
helpful placeholders, but not placeholder-only labels
required fields marked explicitly
inline validation messages
a disabled or loading submit state
For validation, use a schema library or your own server-side checks. The important principle is that validation should happen on the server even if you also validate on the client. Client validation improves usability, but server validation protects data integrity.
A typical Server Action flow looks like this:
User submits form
Server Action receives FormData
Validate input
Insert into database
Invalidate the relevant cache
Redirect or update the UI
Next.js documents forms, pending states, validation errors, and optimistic updates as part of the standard Server Actions workflow. That makes it a natural fit for CRUD creation flows. (nextjs.org)
If you prefer Route Handlers, they work well too, especially when you want an endpoint for non-form clients or external integrations. Use them when the mutation needs to be consumed by more than just your Next.js form. For a classic app-driven workflow, though, Server Actions often keep the code simpler because UI and mutation logic stay close together. (nextjs.org)
A few best practices for creation:
Trim input before saving
Normalize text casing if needed
Return friendly validation feedback
Redirect after success to avoid duplicate submissions
Invalidate the list view so the new record appears immediately
Updating records is usually the most nuanced CRUD operation because users need to see current values, edit them safely, and then observe the new state without confusion. A good pattern is to create an edit route like /items/[id]/edit or an inline edit panel that preloads the existing record.
Prefilled forms should match the current record exactly. That includes text fields, selects, toggles, and any optional values. The goal is to make editing feel like a continuation of the current data rather than a fresh form.
There are two common approaches for the UI after an update:
After the mutation, refresh the relevant data so the page reflects the saved record. This is simple and reliable, especially when the record list is cached.
Update the local interface immediately before the server response arrives. This creates a snappier experience, but you need to handle rollback if the save fails. Next.js forms documentation explicitly covers optimistic updates as part of its Server Actions patterns. (nextjs.org)
For many CRUD apps, revalidated UI is the safer first choice. Once the app is stable, optimistic updates can improve perceived performance for frequent edits.
When updating data on the server, Next.js gives you several ways to refresh the cache. revalidatePath is useful when you want to refresh an entire route. revalidateTag is better when the same data appears in multiple places and you want a more precise refresh. updateTag is the strongest option for “read your own writes” behavior in Server Actions, where the user should see their changes immediately. (nextjs.org)
A practical rule:
Use updateTag when the editing user must see the fresh result right away
Use revalidateTag when stale-while-revalidate is acceptable
Use revalidatePath when route-level refresh is simpler than tag management
Delete is the most dangerous CRUD action, so the user experience should make it hard to do accidentally. A confirmation dialog or inline confirmation step is usually the right choice for record deletion, especially when the data is important or irreversible.
Good delete UX includes:
a clearly labeled delete button
a confirmation prompt for destructive actions
text that explains what will be removed
a loading state while deletion is in progress
Security matters here too. Never trust the client to send a valid id alone. The server should verify permissions, ownership, and the existence of the record before deleting anything. Deletions should also be handled only through secure server-side mutations, not client-side data manipulation.
Once the record is deleted, the cache needs to be updated. Otherwise the user may still see the removed item in a stale list. Next.js recommends using revalidateTag or revalidatePath to invalidate server-side cached data after a mutation, and updateTag when you need immediate freshness in a Server Action. (nextjs.org)
A good delete flow often looks like this:
User clicks Delete
Confirmation dialog appears
User confirms
Server verifies access
Record is deleted
Cache is invalidated
UI returns to the refreshed list
If deletion happens on a detail page, you may want to redirect the user back to the list after success. That reduces the chance of showing a page for a record that no longer exists.

A solid CRUD app should anticipate failure instead of pretending every request will succeed. Errors are not just exceptions; they are part of the normal user experience. A form might fail because a field is missing, a record already exists, the database is down, or the user lacks permission.
There are two broad classes of errors:
These are business-rule or validation errors, such as:
title is required
email is invalid
item already exists
user does not have permission
These should usually be shown inline near the form field or as a message at the top of the form.
These are runtime failures such as:
network timeouts
database connection issues
unhandled exceptions
These should be caught by error boundaries or route-level error handling and displayed in a user-friendly way.
Next.js App Router supports route-level error UIs through error.tsx, which is useful when a page or segment encounters a failure. Combined with form validation and loading states, this gives you a much more resilient experience. (nextjs.org)
Accessible error handling is just as important as visible error handling. Good patterns include:
associating error text with the relevant input
using aria-describedby for inline validation
keeping focus on the first invalid field
announcing submission status for screen readers
avoiding color-only error cues
For server-side validation, return structured errors that your UI can display predictably. For example, instead of throwing a vague error, return something like:
fieldErrors.title = "Title is required"
formError = "Unable to save the item right now"
This approach keeps the form usable and reduces frustration. It also makes the app easier to maintain because your UI and server logic share a clear error contract.
revalidatePath, revalidateTag, or updateTag for Fresh DataCaching is one of the biggest reasons Next.js feels fast, but CRUD apps need a smart strategy so users do not see stale data after they create, edit, or delete a record. Next.js now emphasizes tagged caching and on-demand invalidation, which gives you finer control than route-only refreshes. (nextjs.org)
Here is the practical difference:
revalidatePathUse this when you want to refresh a specific route path, such as /items. It is straightforward and useful when the page is the primary place the data appears. Next.js says tag-based invalidation is usually more precise, but revalidatePath is still handy when route scoping is the simplest solution. (nextjs.org)
revalidateTagUse this when the same data is shared across multiple routes or components. You attach a tag to fetched data and invalidate all matching cached entries together. Next.js notes that revalidateTag uses stale-while-revalidate behavior, which is good when a brief delay is acceptable. It can be used in Server Actions and Route Handlers. (nextjs.org)
updateTagUse this in Server Actions when you want the data to expire immediately and show the latest version right away. Next.js describes it as the best choice for read-your-own-writes scenarios. (nextjs.org)
A simple decision framework:
Need immediate freshness after a form submit? Use updateTag
Need freshness across multiple pages? Use revalidateTag
Need to refresh a single route? Use revalidatePath
If you are using a cached fetch or cached query layer, plan your tags up front. For example, tag all item-list reads with items, and all item-detail reads with item:${id}. Then invalidate only what changed. That keeps the app fast and avoids unnecessary refetching. Next.js also notes that CDN caching alone does not replace on-demand server revalidation, so server cache strategy still matters even after deployment. (nextjs.org)
Once the CRUD workflow works, polish makes the app feel complete. Start with layout and spacing: ensure the form, table, and action buttons look good on both desktop and mobile. Since create-next-app can include Tailwind CSS by default, responsive styling is relatively straightforward to add during setup. (nextjs.org)
A polished CRUD app should include:
consistent button styles
clear hover and focus states
readable typography
responsive table behavior
decent spacing on mobile screens
a visible loading indicator during mutations
Testing matters too. At a minimum, test:
creating a valid record
rejecting invalid input
editing an existing record
deleting a record
cache refresh after mutation
empty-state rendering
Performance checks are also important. Since Next.js can render server-side and cache data intelligently, you want to avoid over-fetching and unnecessary client state. Keep database reads on the server when possible, and use client components only where interactivity is needed. The App Router model is especially effective when you lean into server rendering and targeted client islands. (nextjs.org)
For deployment, keep these points in mind:
use environment variables for database credentials
verify production build behavior locally before deploying
ensure your database provider supports the deployment environment
check that revalidation behaves correctly in production
confirm auth and permission checks on the server
If you deploy to a platform that supports Next.js well, the process is usually smooth, but the app should still be tested with real production-like data. That is especially true for CRUD apps, where stale caches, duplicate submissions, and validation edge cases can hide until the app is under real use.
Building a simple CRUD app in Next.js is one of the most practical ways to learn modern full-stack development. You get hands-on experience with the App Router, TypeScript, forms, server-side mutations, caching, and deployment-friendly architecture all in one project. Next.js is a strong fit because it lets you keep the UI and server logic close together while still supporting clean separation where needed. (nextjs.org)
The key takeaways are straightforward:
start with a clear data model
choose a storage option that matches your goals
build a strong read view with loading and empty states
handle create, update, and delete through secure server-side mutations
validate on the server and show accessible errors
use revalidatePath, revalidateTag, or updateTag thoughtfully to keep data fresh
finish with responsive styling, testing, and deployment checks. (nextjs.org)
If you keep the app simple first and improve it step by step, you will end up with a solid foundation for much larger projects.