Remix: Data mutations using forms and actions

Join the AI Workshop to learn more about AI and how it can be applied to web development. Next cohort February 1st, 2026

The AI-first Web Development BOOTCAMP cohort starts February 24th, 2026. 10 weeks of intensive training and hands-on projects.


Let’s add a little form to add a new blog post.

There will be no authentication here, so anyone can add a blog post.

Not something recommended to have on a public site of course, but will work for our sample app to demonstrate mutations.

Create a new route component in app/routes/blog.new.jsx so it will “respond” on the URL /blog/new.

You can link to it in the component from app/routes/blog.jsx:

//...
        <ul className='list-disc list-inside row-span-1'>
          {posts.map((post) => (
            <li key={post.id}>
              <NavLink
                to={`/blog/${post.id}`}
                className={({ isActive }) => (isActive ? 'font-bold' : '')}>
                {post.title}
              </NavLink>
            </li>
          ))}
          <li>
            <NavLink
              to='/blog/new'
              className={({ isActive }) => (isActive ? 'font-bold' : '')}>
              New post
            </NavLink>
          </li>
        </ul>
//...

Now in app/routes/blog.new.jsx start by creating a component:

export const meta = () => {
  return [{ title: 'New post' }]
}

export default function NewPost() {

  return (
    //...
  )
}

Now we create a form using the Form component that’s provided by Remix:

import { Form } from '@remix-run/react'

export const meta = () => {
  return [{ title: 'New post' }]
}

export default function NewPost() {
  return (
    <Form method='post'>
      <label>
        Title: <input className='border' name='title' required='true' />
      </label>
      <button type='submit' className='p-2 bg-zinc-300 border'>
        Create post
      </button>
    </Form>
  )
}

When the form is submitted, Remix executes the action on the route.

So we add it by adding a named function export action.

In there we first get the post title from the form data:

export async function action({ request }) {
  const formData = await request.formData()
  const title = formData.get('title')

  //...
}

Then we insert this new post in the database, and we redirect to it:

import { redirect } from '@remix-run/node'
import { getDb } from '../database.server.js'

//...

export async function action({ request }) {
  const formData = await request.formData()
  const title = formData.get('title')

  const db = await getDb()
  const result = await db.run('INSERT INTO posts (title) VALUES (?)', title)
  await db.close()

  return redirect(`/blog/${result.lastID}`)
}

Here’s the full code of the form fully working:

import { Form } from '@remix-run/react'
import { redirect } from '@remix-run/node'
import { getDb } from '../database.server.js'

export const meta = () => {
  return [{ title: 'New post' }]
}

export async function action({ request }) {
  const formData = await request.formData()
  const title = formData.get('title')

  const db = await getDb()
  const result = await db.run('INSERT INTO posts (title) VALUES (?)', title)
  await db.close()

  return redirect(`/blog/${result.lastID}`)
}

export default function NewPost() {
  return (
    <Form method='post'>
      <label>
        Title: <input className='border' name='title' required='true' />
      </label>
      <button type='submit' className='p-2 bg-zinc-300 border'>
        Create post
      </button>
    </Form>
  )
}

Lessons in this unit:

0: Introduction
1: Create your first Remix app
2: The root route
3: File based routing
4: Linking your pages
5: Styling with CSS and Tailwind
6: Create a navigation
7: Dynamic routes and nested routes
8: Connecting a database
9: ▶︎ Data mutations using forms and actions
10: Introduction to Remix