If you ask a Serverpod developer what they appreciate most about the framework, code generation comes up almost every time.
If you ask someone who just started with Serverpod what confuses them most, code generation comes up almost every time.
That's not a contradiction. It's the nature of a feature that fundamentally changes how a framework operates — it looks like overhead before you understand it and feels like a superpower once you do. The gap between those two experiences is mostly a matter of having a clear explanation of what's actually happening when you run serverpod generate, why the framework is designed this way, and what it buys you that you'd otherwise pay for in a different way.
This article is that explanation.
The Problem Code Generation Solves
To understand why Serverpod uses code generation, it helps to understand the problem it's solving — and to appreciate that the problem is one every Flutter developer has already encountered, even if they haven't named it.
When a Flutter app communicates with a backend, it exchanges data. The backend produces a JSON response. The Flutter app receives that JSON, parses it, and maps it to a typed Dart object it can work with. That mapping — from raw JSON to a typed model — is something you write manually in most setups. A fromJson factory that reads fields from a map. A toJson method that does the reverse. Field names that might be snake_case in the JSON because the backend uses that convention, but camelCase in the Dart class because Dart uses that convention. The bookkeeping required to keep those two representations aligned whenever one of them changes.
This is such a routine part of Flutter development that most developers have internalised it as just the work. But it's a meaningful cost — not just in time spent writing serialisation code, but in the category of bugs it introduces. Type mismatches discovered at runtime rather than compile time. A field renamed on the server that breaks the client silently because nobody updated the fromJson factory. An optional field treated as required because the documentation was out of date when the Flutter developer last read it.
The deeper version of this problem is that the backend and the Flutter client are maintaining separate representations of the same data. They describe the same concepts, but in different places, in different ways, with no structural guarantee that they stay in sync. The synchronisation is enforced by convention — by the discipline of whoever wrote the code, by the thoroughness of code review, by communication between frontend and backend developers. All of which work most of the time, and none of which prevent the class of bugs that come from the two sides drifting apart.
Code generation solves this by making the separate representations unnecessary. There is one definition. Everything else follows from it.
What Gets Generated and From What
Serverpod's code generation works from two sources: your model definition files and your endpoint classes.
Model definitions are YAML files with a .spy.yaml extension. Each one describes a data structure — the fields, their types, any relationships to other models, and whether it maps to a database table. They look like this:
class: Article
table: articles
fields:
title: String
body: String
publishedAt: DateTime?
authorId: int
That's the complete definition. From it, Serverpod's generator produces a Dart class named Article with those four fields, typed correctly including the nullable publishedAt. It produces serialisation and deserialisation methods. It produces the database mapping that tells the ORM how to read and write rows in the articles table. It produces everything that class needs to exist and function across your entire application.
You don't write any of that. You write six lines of YAML and run the generator.
Endpoint classes are the Dart files in your server package where you define what your server can do. The generator reads these and produces the client-side stubs — the Dart code your Flutter app uses to call those endpoints. When you write this on the server:
class ArticleEndpoint extends Endpoint {
Future<List<Article>> getPublishedArticles(Session session) async {
return await Article.db.find(
session,
where: (a) => a.publishedAt.notEquals(null),
);
}
Future<Article> createArticle(
Session session,
String title,
String body,
) async {
final article = Article(
title: title,
body: body,
publishedAt: null,
authorId: session.auth.authenticatedUserId!,
);
return await Article.db.insertRow(session, article);
}
}
The generator produces a client class in your Flutter package with methods that mirror these exactly. The method names match. The parameter types match. The return types match. Calling client.article.getPublishedArticles() from your Flutter app returns a Future<List<Article>> — the same Article class, from the same shared package, that your server returned.
Where the Generated Code Lives
One of the first things that trips up new Serverpod developers is understanding the project structure that code generation creates and maintains.
A Serverpod project has three packages:
The server package is where you work. Your model YAML files live here. Your endpoint Dart classes live here. Your business logic lives here. This is the package you own and edit freely.
The shared package contains the generated model classes — the Dart classes produced from your YAML definitions. Both your server and your Flutter client import from this package, which is what makes the types genuinely shared rather than just coincidentally similar. When your Flutter app uses an Article object, it's using the same class definition that your server uses, because they both come from the same generated file in the shared package.
The client package contains the generated client stubs — the code that your Flutter app uses to call your endpoints. This package is almost entirely generated. You don't write code here. You import it.
The relationship between these packages is the mechanism that makes type safety across the stack possible. The shared package is the common ground. When your model changes, the shared package is regenerated, and every consumer of that package — server and client — gets the updated version. The compiler tells you everywhere that needs to change.
What Happens When You Run serverpod generate
The generation command is the step that moves a change you made in the definition layer into the rest of your project. Understanding what it actually does during that step demystifies a lot of the confusion around it.
When you run serverpod generate, the tool does several things in sequence.
It reads all of your .spy.yaml model definition files and validates their structure — checking that field types are valid, that relationships reference models that exist, that the syntax is correct. If something is wrong here, the generator tells you and stops. This is where typos and structural errors surface, before any code is produced.
It reads your endpoint class files and analyses their method signatures — the parameter types, the return types, the method names. It understands the Dart type system well enough to follow references from endpoints to models and produce client code that accurately reflects the server's interface.
It writes into the shared and client packages. The generated files are overwritten completely each time the generator runs. There is no merging of generated content with content you've written. Generated files are owned entirely by the generator — any manual change you make to them will be overwritten on the next generation run, which is why the convention is to never edit generated files directly.
Finally, it produces the migration scaffolding when your model changes affect the database schema — adding a field, removing a table, changing a relationship. The actual migration file is created separately with serverpod create-migration, but the generator prepares the information that migration creation needs.
The whole process takes a few seconds for a typical project. For larger projects with many models and endpoints, it stays fast because the generator is doing straightforward text transformation and analysis rather than anything computationally expensive.
The Feedback Loop and How to Work With It
The development workflow that code generation creates is different from the workflow most Flutter developers are used to, and the friction people experience with it usually comes from trying to work against it rather than with it.
The workflow Serverpod asks you to develop is this: make your changes in the definition layer first, then generate, then implement.
If you want to add a field to a model, you add it to the YAML file before you write any code that uses it. Then you generate. The Dart class gains the new field. Now you write the code that uses it — on both the server and the client — with the compiler checking that your usage matches the definition.
If you want to add a new endpoint method, you write the method signature before you write the body. Then you generate. The client stub appears. Now you can write the Flutter code that calls it — with a real method to call and real types to work with — while simultaneously writing the server-side implementation.
This order feels backwards to developers who are used to writing code first and worrying about integration later. The instinct is to write the implementation and then figure out how to expose it. Serverpod inverts that instinct. Define first, generate, then implement. The payoff for that inversion is that when you reach the implementation phase, the integration between client and server already exists and is already type-safe.
The other thing to develop as a habit is running generation frequently — more frequently than feels necessary at first. Every time you touch a model definition or an endpoint signature, run the generator before doing anything else. The cost is a few seconds. The benefit is that the generated code in your project always reflects your current definitions, and the gap between "what I've defined" and "what the compiler knows about" stays closed.
Why This Changes What's Possible
Code generation in Serverpod is not primarily a convenience feature. It is the mechanism that makes the full-stack Dart promise real rather than theoretical.
Without code generation, you could still write a Dart backend for your Flutter app. You'd write Dart on the server. You'd write Dart on the client. But you'd maintain the integration between them manually — the serialisation code, the client-side API calls, the mapping between what the server produces and what the client expects. The shared language would be a nice property, but the shared-type guarantee wouldn't exist. The bugs that live in the gap between frontend and backend representations would still be there, just written in Dart rather than across a language boundary.
With code generation, the integration is structural. The types are shared because they come from the same generated source. The method signatures are guaranteed to match because the client code is produced directly from the server code. The serialisation is generated from the same model definition that produces the Dart class, so the mapping between wire format and in-memory object is always consistent.
This is what "type-safe end to end" actually means in practice. Not that you've been disciplined about your types on both sides. But that the framework has made inconsistency structurally difficult. The category of bugs that comes from frontend and backend drifting apart doesn't require discipline to prevent — it requires a model change to introduce, and a generation run to propagate correctly.
The One Thing to Accept About It
There is one thing about Serverpod's code generation that takes adjustment, and it's worth naming directly.
The generated code is not yours to edit. It will be overwritten. Any change you make to a generated file — adding a helper method, adjusting a generated class, fixing something in the client stubs — disappears the next time you run the generator. This means that the instinct to just fix something quickly in the generated output is an instinct to resist. If something about the generated output needs to change, the change belongs in the definition that produces it.
This is not a limitation so much as a contract. The generator owns certain files. You own the rest. The project structure makes that boundary visible. Once you internalise where the boundary is, it stops feeling like a constraint and starts feeling like clarity — a clear division between the code you're responsible for and the code the framework produces on your behalf.
That clarity, held consistently, is what allows code generation to do its job — which is to make the bridge between your Flutter app and your Dart backend something you define once and never maintain again.
That's a different relationship with integration work than most Flutter developers have experienced. And once you've had it, the manual version is hard to go back to.
