Build a Simple CRUD App in Next.js: A Modern Step-by-Step Guide

Build a Simple CRUD App in Next.js: A Modern Step-by-Step Guide

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.

General illustration of a CRUD app workflow

1) Introduction: What a CRUD App Is and Why Next.js Fits So Well

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)

2) Project Setup: Create a Next.js App, Choose TypeScript, and Organize the App Router Structure

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-app

When 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.ts

This 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)

3) Data Model and Storage Options: Local Mock Data, JSON, SQLite, PostgreSQL, or a Hosted Database

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.

Local mock data

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.

JSON file storage

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

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

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.

Hosted database

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

4) Build the Read View: Render a List or Table of Records with Loading and Empty States

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:

  1. 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)

  2. 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.”

  3. 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.

5) Create Records: Design a Form, Validate Input, and Submit with Server Actions or Route Handlers

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:

  1. User submits form

  2. Server Action receives FormData

  3. Validate input

  4. Insert into database

  5. Invalidate the relevant cache

  6. 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

6) Update Records: Edit Forms, Prefilled Values, and Optimistic or Revalidated UI Updates

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:

Revalidated UI

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.

Optimistic UI

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

7) Delete Records: Confirmation UX, Secure Mutation Handling, and Cache Invalidation

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:

  1. User clicks Delete

  2. Confirmation dialog appears

  3. User confirms

  4. Server verifies access

  5. Record is deleted

  6. Cache is invalidated

  7. 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.

Comparison table for CRUD mutation strategies

8) Error Handling and Validation: Expected Errors, Form Feedback, and Accessible UI Patterns

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:

Expected 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.

Unexpected errors

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.

9) Caching and Revalidation: When to Use revalidatePath, revalidateTag, or updateTag for Fresh Data

Caching 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:

revalidatePath

Use 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)

revalidateTag

Use 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)

updateTag

Use 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)

10) Polish and Deploy: Styling, Responsiveness, Testing, Performance Checks, and Deployment Considerations

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.

Conclusion

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.

References